();
for (const seed of seedEntries) {
@@ -111,6 +167,7 @@ for (const seed of seedEntries) {
apiByLogin.set(key, user);
const existing = entriesByKey.get(key);
if (!existing) {
+ const fd = firstCommitByLogin.get(key) ?? "";
entriesByKey.set(key, {
key,
login: user.login,
@@ -118,6 +175,10 @@ for (const seed of seedEntries) {
html_url: user.html_url,
avatar_url: user.avatar_url,
lines: 0,
+ commits: 0,
+ prs: 0,
+ score: 0,
+ firstCommitDate: fd,
});
} else {
existing.display = existing.display || seed.display;
@@ -150,28 +211,40 @@ for (const item of contributors) {
const existing = entriesByKey.get(key);
if (!existing) {
- const lines = linesByLogin.get(key) ?? 0;
- const contributions = contributionsByLogin.get(key) ?? 0;
+ const loc = linesByLogin.get(key) ?? 0;
+ const commits = contributionsByLogin.get(key) ?? 0;
+ const prs = prsByLogin.get(key) ?? 0;
+ const fd = firstCommitByLogin.get(key) ?? "";
entriesByKey.set(key, {
key,
login: user.login,
display: pickDisplay(baseName, user.login),
html_url: user.html_url,
avatar_url: normalizeAvatar(user.avatar_url),
- lines: lines > 0 ? lines : contributions,
+ lines: loc > 0 ? loc : commits,
+ commits,
+ prs,
+ score: computeScore(loc, commits, prs, fd),
+ firstCommitDate: fd,
});
} else {
existing.login = user.login;
existing.display = pickDisplay(baseName, user.login, existing.display);
existing.html_url = user.html_url;
existing.avatar_url = normalizeAvatar(user.avatar_url);
- const lines = linesByLogin.get(key) ?? 0;
- const contributions = contributionsByLogin.get(key) ?? 0;
- existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions);
+ const loc = linesByLogin.get(key) ?? 0;
+ const commits = contributionsByLogin.get(key) ?? 0;
+ const prs = prsByLogin.get(key) ?? 0;
+ const fd = firstCommitByLogin.get(key) ?? existing.firstCommitDate;
+ existing.lines = Math.max(existing.lines, loc > 0 ? loc : commits);
+ existing.commits = Math.max(existing.commits, commits);
+ existing.prs = Math.max(existing.prs, prs);
+ existing.firstCommitDate = fd || existing.firstCommitDate;
+ existing.score = Math.max(existing.score, computeScore(loc, commits, prs, fd));
}
}
-for (const [login, lines] of linesByLogin.entries()) {
+for (const [login, loc] of linesByLogin.entries()) {
if (entriesByKey.has(login)) {
continue;
}
@@ -180,14 +253,20 @@ for (const [login, lines] of linesByLogin.entries()) {
user = fetchUser(login) || undefined;
}
if (user) {
- const contributions = contributionsByLogin.get(login) ?? 0;
+ const commits = contributionsByLogin.get(login) ?? 0;
+ const prs = prsByLogin.get(login) ?? 0;
+ const fd = firstCommitByLogin.get(login) ?? "";
entriesByKey.set(login, {
key: login,
login: user.login,
display: displayName[user.login.toLowerCase()] ?? user.login,
html_url: user.html_url,
avatar_url: normalizeAvatar(user.avatar_url),
- lines: lines > 0 ? lines : contributions,
+ lines: loc > 0 ? loc : commits,
+ commits,
+ prs,
+ score: computeScore(loc, commits, prs, fd),
+ firstCommitDate: fd,
});
}
}
@@ -195,22 +274,22 @@ for (const [login, lines] of linesByLogin.entries()) {
const entries = Array.from(entriesByKey.values());
entries.sort((a, b) => {
- if (b.lines !== a.lines) {
- return b.lines - a.lines;
+ if (b.score !== a.score) {
+ return b.score - a.score;
}
return a.display.localeCompare(b.display);
});
-const lines: string[] = [];
+const htmlLines: string[] = [];
for (let i = 0; i < entries.length; i += PER_LINE) {
const chunk = entries.slice(i, i + PER_LINE);
const parts = chunk.map((entry) => {
return `
`;
});
- lines.push(` ${parts.join(" ")}`);
+ htmlLines.push(` ${parts.join(" ")}`);
}
-const block = `${lines.join("\n")}\n`;
+const block = `${htmlLines.join("\n")}\n`;
const readme = readFileSync(readmePath, "utf8");
const start = readme.indexOf('');
const end = readme.indexOf("
", start);
@@ -223,6 +302,24 @@ const next = `${readme.slice(0, start)}\n${block}${readme.slice(
writeFileSync(readmePath, next);
console.log(`Updated README clawtributors: ${entries.length} entries`);
+console.log(`\nTop 25 by composite score: (commits*2 + PRs*10 + sqrt(LOC)) * tenure`);
+console.log(` tenure = 1.0 + (days_since_first_commit / repo_age)^2 * 0.5`);
+console.log(
+ `${"#".padStart(3)} ${"login".padEnd(24)} ${"score".padStart(8)} ${"tenure".padStart(7)} ${"commits".padStart(8)} ${"PRs".padStart(6)} ${"LOC".padStart(10)} first commit`,
+);
+console.log("-".repeat(85));
+for (const entry of entries.slice(0, 25)) {
+ const login = (entry.login ?? entry.key).slice(0, 24);
+ const fd = entry.firstCommitDate || "?";
+ const daysIn =
+ fd !== "?" ? Math.max(0, (now - new Date(fd.slice(0, 10)).getTime()) / 86_400_000) : 0;
+ const tr = Math.min(1, daysIn / repoAgeDays);
+ const tenure = 1.0 + tr * tr * 0.5;
+ console.log(
+ `${entries.indexOf(entry) + 1}`.padStart(3) +
+ ` ${login.padEnd(24)} ${entry.score.toFixed(0).padStart(8)} ${tenure.toFixed(2).padStart(6)}x ${String(entry.commits).padStart(8)} ${String(entry.prs).padStart(6)} ${String(entry.lines).padStart(10)} ${fd}`,
+ );
+}
function run(cmd: string): string {
return execSync(cmd, {
diff --git a/scripts/update-clawtributors.types.ts b/scripts/update-clawtributors.types.ts
index 98526bc8a41..631060d4655 100644
--- a/scripts/update-clawtributors.types.ts
+++ b/scripts/update-clawtributors.types.ts
@@ -29,4 +29,8 @@ export type Entry = {
html_url: string;
avatar_url: string;
lines: number;
+ commits: number;
+ prs: number;
+ score: number;
+ firstCommitDate: string;
};
diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts
index 8cc029b8e45..9b07fafc4da 100644
--- a/src/agents/openclaw-tools.sessions.test.ts
+++ b/src/agents/openclaw-tools.sessions.test.ts
@@ -876,6 +876,59 @@ describe("sessions tools", () => {
expect(details.text).toContain("recent (last 30m):");
});
+ it("subagents list keeps ended orchestrators active while descendants are pending", async () => {
+ resetSubagentRegistryForTests();
+ const now = Date.now();
+ addSubagentRunForTests({
+ runId: "run-orchestrator-ended",
+ childSessionKey: "agent:main:subagent:orchestrator-ended",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "orchestrate child workers",
+ cleanup: "keep",
+ createdAt: now - 5 * 60_000,
+ startedAt: now - 5 * 60_000,
+ endedAt: now - 4 * 60_000,
+ outcome: { status: "ok" },
+ });
+ addSubagentRunForTests({
+ runId: "run-orchestrator-child-active",
+ childSessionKey: "agent:main:subagent:orchestrator-ended:subagent:child",
+ requesterSessionKey: "agent:main:subagent:orchestrator-ended",
+ requesterDisplayKey: "subagent:orchestrator-ended",
+ task: "child worker still running",
+ cleanup: "keep",
+ createdAt: now - 60_000,
+ startedAt: now - 60_000,
+ });
+
+ const tool = createOpenClawTools({
+ agentSessionKey: "agent:main:main",
+ }).find((candidate) => candidate.name === "subagents");
+ expect(tool).toBeDefined();
+ if (!tool) {
+ throw new Error("missing subagents tool");
+ }
+
+ const result = await tool.execute("call-subagents-list-orchestrator", { action: "list" });
+ const details = result.details as {
+ status?: string;
+ active?: Array<{ runId?: string; status?: string }>;
+ recent?: Array<{ runId?: string }>;
+ };
+
+ expect(details.status).toBe("ok");
+ expect(details.active).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ runId: "run-orchestrator-ended",
+ status: "active",
+ }),
+ ]),
+ );
+ expect(details.recent?.find((entry) => entry.runId === "run-orchestrator-ended")).toBeFalsy();
+ });
+
it("subagents list usage separates io tokens from prompt/cache", async () => {
resetSubagentRegistryForTests();
const now = Date.now();
diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts
index dc0d492f02d..c82151e6515 100644
--- a/src/agents/subagent-announce.format.e2e.test.ts
+++ b/src/agents/subagent-announce.format.e2e.test.ts
@@ -34,6 +34,8 @@ const embeddedRunMock = {
const subagentRegistryMock = {
isSubagentSessionRunActive: vi.fn(() => true),
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
+ countPendingDescendantRuns: vi.fn((_sessionKey: string) => 0),
+ countPendingDescendantRunsExcludingRun: vi.fn((_sessionKey: string, _runId: string) => 0),
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
};
const subagentDeliveryTargetHookMock = vi.fn(
@@ -172,6 +174,16 @@ describe("subagent announce formatting", () => {
embeddedRunMock.waitForEmbeddedPiRunEnd.mockClear().mockResolvedValue(true);
subagentRegistryMock.isSubagentSessionRunActive.mockClear().mockReturnValue(true);
subagentRegistryMock.countActiveDescendantRuns.mockClear().mockReturnValue(0);
+ subagentRegistryMock.countPendingDescendantRuns
+ .mockClear()
+ .mockImplementation((sessionKey: string) =>
+ subagentRegistryMock.countActiveDescendantRuns(sessionKey),
+ );
+ subagentRegistryMock.countPendingDescendantRunsExcludingRun
+ .mockClear()
+ .mockImplementation((sessionKey: string, _runId: string) =>
+ subagentRegistryMock.countPendingDescendantRuns(sessionKey),
+ );
subagentRegistryMock.resolveRequesterForChildSession.mockClear().mockReturnValue(null);
hasSubagentDeliveryTargetHook = false;
hookRunnerMock.hasHooks.mockClear();
@@ -408,6 +420,45 @@ describe("subagent announce formatting", () => {
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
});
+ it("keeps direct completion send when only the announcing run itself is pending", async () => {
+ sessionStore = {
+ "agent:main:subagent:test": {
+ sessionId: "child-session-self-pending",
+ },
+ "agent:main:main": {
+ sessionId: "requester-session-self-pending",
+ },
+ };
+ chatHistoryMock.mockResolvedValueOnce({
+ messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: done" }] }],
+ });
+ subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
+ sessionKey === "agent:main:main" ? 1 : 0,
+ );
+ subagentRegistryMock.countPendingDescendantRunsExcludingRun.mockImplementation(
+ (sessionKey: string, runId: string) =>
+ sessionKey === "agent:main:main" && runId === "run-direct-self-pending" ? 0 : 1,
+ );
+
+ const didAnnounce = await runSubagentAnnounceFlow({
+ childSessionKey: "agent:main:subagent:test",
+ childRunId: "run-direct-self-pending",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
+ ...defaultOutcomeAnnounce,
+ expectsCompletionMessage: true,
+ });
+
+ expect(didAnnounce).toBe(true);
+ expect(subagentRegistryMock.countPendingDescendantRunsExcludingRun).toHaveBeenCalledWith(
+ "agent:main:main",
+ "run-direct-self-pending",
+ );
+ expect(sendSpy).toHaveBeenCalledTimes(1);
+ expect(agentSpy).not.toHaveBeenCalled();
+ });
+
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts
index 00f779c3314..996c34b0e6e 100644
--- a/src/agents/subagent-announce.timeout.test.ts
+++ b/src/agents/subagent-announce.timeout.test.ts
@@ -53,6 +53,7 @@ vi.mock("./pi-embedded.js", () => ({
vi.mock("./subagent-registry.js", () => ({
countActiveDescendantRuns: () => 0,
+ countPendingDescendantRuns: () => 0,
isSubagentSessionRunActive: () => true,
resolveRequesterForChildSession: () => null,
}));
diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts
index 5932594d301..3b45234ea12 100644
--- a/src/agents/subagent-announce.ts
+++ b/src/agents/subagent-announce.ts
@@ -728,6 +728,7 @@ async function sendSubagentAnnounceDirectly(params: {
completionRouteMode?: "bound" | "fallback" | "hook";
spawnMode?: SpawnSubagentMode;
directIdempotencyKey: string;
+ currentRunId?: string;
completionDirectOrigin?: DeliveryContext;
directOrigin?: DeliveryContext;
requesterIsSubagent: boolean;
@@ -770,19 +771,35 @@ async function sendSubagentAnnounceDirectly(params: {
(params.completionRouteMode === "bound" || params.completionRouteMode === "hook");
let shouldSendCompletionDirectly = true;
if (!forceBoundSessionDirectDelivery) {
- let activeDescendantRuns = 0;
+ let pendingDescendantRuns = 0;
try {
- const { countActiveDescendantRuns } = await import("./subagent-registry.js");
- activeDescendantRuns = Math.max(
- 0,
- countActiveDescendantRuns(canonicalRequesterSessionKey),
- );
+ const {
+ countPendingDescendantRuns,
+ countPendingDescendantRunsExcludingRun,
+ countActiveDescendantRuns,
+ } = await import("./subagent-registry.js");
+ if (params.currentRunId && typeof countPendingDescendantRunsExcludingRun === "function") {
+ pendingDescendantRuns = Math.max(
+ 0,
+ countPendingDescendantRunsExcludingRun(
+ canonicalRequesterSessionKey,
+ params.currentRunId,
+ ),
+ );
+ } else {
+ pendingDescendantRuns = Math.max(
+ 0,
+ typeof countPendingDescendantRuns === "function"
+ ? countPendingDescendantRuns(canonicalRequesterSessionKey)
+ : countActiveDescendantRuns(canonicalRequesterSessionKey),
+ );
+ }
} catch {
// Best-effort only; when unavailable keep historical direct-send behavior.
}
// Keep non-bound completion announcements coordinated via requester
- // session routing while sibling/descendant runs are still active.
- if (activeDescendantRuns > 0) {
+ // session routing while sibling or descendant runs are still pending.
+ if (pendingDescendantRuns > 0) {
shouldSendCompletionDirectly = false;
}
}
@@ -899,6 +916,7 @@ async function deliverSubagentAnnouncement(params: {
completionRouteMode?: "bound" | "fallback" | "hook";
spawnMode?: SpawnSubagentMode;
directIdempotencyKey: string;
+ currentRunId?: string;
signal?: AbortSignal;
}): Promise {
return await runSubagentAnnounceDispatch({
@@ -922,6 +940,7 @@ async function deliverSubagentAnnouncement(params: {
completionMessage: params.completionMessage,
internalEvents: params.internalEvents,
directIdempotencyKey: params.directIdempotencyKey,
+ currentRunId: params.currentRunId,
completionDirectOrigin: params.completionDirectOrigin,
completionRouteMode: params.completionRouteMode,
spawnMode: params.spawnMode,
@@ -1203,16 +1222,23 @@ export async function runSubagentAnnounceFlow(params: {
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
- let activeChildDescendantRuns = 0;
+ let pendingChildDescendantRuns = 0;
try {
- const { countActiveDescendantRuns } = await import("./subagent-registry.js");
- activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey));
+ const { countPendingDescendantRuns, countActiveDescendantRuns } =
+ await import("./subagent-registry.js");
+ pendingChildDescendantRuns = Math.max(
+ 0,
+ typeof countPendingDescendantRuns === "function"
+ ? countPendingDescendantRuns(params.childSessionKey)
+ : countActiveDescendantRuns(params.childSessionKey),
+ );
} catch {
// Best-effort only; fall back to direct announce behavior when unavailable.
}
- if (activeChildDescendantRuns > 0) {
- // The finished run still has active descendant subagents. Defer announcing
- // this run until descendants settle so we avoid posting in-progress updates.
+ if (pendingChildDescendantRuns > 0) {
+ // The finished run still has pending descendant subagents (either active,
+ // or ended but still finishing their own announce and cleanup flow). Defer
+ // announcing this run until descendants fully settle.
shouldDeleteChildSession = false;
return false;
}
@@ -1383,6 +1409,7 @@ export async function runSubagentAnnounceFlow(params: {
completionRouteMode: completionResolution.routeMode,
spawnMode: params.spawnMode,
directIdempotencyKey,
+ currentRunId: params.childRunId,
signal: params.signal,
});
// Cron delivery state should only be marked as delivered when we have a
diff --git a/src/agents/subagent-registry-cleanup.test.ts b/src/agents/subagent-registry-cleanup.test.ts
new file mode 100644
index 00000000000..ed97add7162
--- /dev/null
+++ b/src/agents/subagent-registry-cleanup.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { resolveDeferredCleanupDecision } from "./subagent-registry-cleanup.js";
+import type { SubagentRunRecord } from "./subagent-registry.types.js";
+
+function makeEntry(overrides: Partial = {}): SubagentRunRecord {
+ return {
+ runId: "run-1",
+ childSessionKey: "agent:main:subagent:child",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "test",
+ cleanup: "keep",
+ createdAt: 0,
+ endedAt: 1_000,
+ ...overrides,
+ };
+}
+
+describe("resolveDeferredCleanupDecision", () => {
+ const now = 2_000;
+
+ it("defers completion-message cleanup while descendants are still pending", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: true }),
+ now,
+ activeDescendantRuns: 2,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: () => 2_000,
+ });
+
+ expect(decision).toEqual({ kind: "defer-descendants", delayMs: 1_000 });
+ });
+
+ it("hard-expires completion-message cleanup when descendants never settle", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: true, endedAt: now - (30 * 60_000 + 1) }),
+ now,
+ activeDescendantRuns: 1,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: () => 2_000,
+ });
+
+ expect(decision).toEqual({ kind: "give-up", reason: "expiry" });
+ });
+
+ it("keeps regular expiry behavior for non-completion flows", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: false, endedAt: now - (5 * 60_000 + 1) }),
+ now,
+ activeDescendantRuns: 0,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: () => 2_000,
+ });
+
+ expect(decision).toEqual({ kind: "give-up", reason: "expiry", retryCount: 1 });
+ });
+
+ it("uses retry backoff for completion-message flows once descendants are settled", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: true, announceRetryCount: 1 }),
+ now,
+ activeDescendantRuns: 0,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: (retryCount) => retryCount * 1_000,
+ });
+
+ expect(decision).toEqual({ kind: "retry", retryCount: 2, resumeDelayMs: 2_000 });
+ });
+});
diff --git a/src/agents/subagent-registry-cleanup.ts b/src/agents/subagent-registry-cleanup.ts
index 4e3f8f83300..716e6e2a72a 100644
--- a/src/agents/subagent-registry-cleanup.ts
+++ b/src/agents/subagent-registry-cleanup.ts
@@ -35,20 +35,27 @@ export function resolveDeferredCleanupDecision(params: {
now: number;
activeDescendantRuns: number;
announceExpiryMs: number;
+ announceCompletionHardExpiryMs: number;
maxAnnounceRetryCount: number;
deferDescendantDelayMs: number;
resolveAnnounceRetryDelayMs: (retryCount: number) => number;
}): DeferredCleanupDecision {
const endedAgo = resolveEndedAgoMs(params.entry, params.now);
- if (params.entry.expectsCompletionMessage === true && params.activeDescendantRuns > 0) {
- if (endedAgo > params.announceExpiryMs) {
+ const isCompletionMessageFlow = params.entry.expectsCompletionMessage === true;
+ const completionHardExpiryExceeded =
+ isCompletionMessageFlow && endedAgo > params.announceCompletionHardExpiryMs;
+ if (isCompletionMessageFlow && params.activeDescendantRuns > 0) {
+ if (completionHardExpiryExceeded) {
return { kind: "give-up", reason: "expiry" };
}
return { kind: "defer-descendants", delayMs: params.deferDescendantDelayMs };
}
const retryCount = (params.entry.announceRetryCount ?? 0) + 1;
- if (retryCount >= params.maxAnnounceRetryCount || endedAgo > params.announceExpiryMs) {
+ const expiryExceeded = isCompletionMessageFlow
+ ? completionHardExpiryExceeded
+ : endedAgo > params.announceExpiryMs;
+ if (retryCount >= params.maxAnnounceRetryCount || expiryExceeded) {
return {
kind: "give-up",
reason: retryCount >= params.maxAnnounceRetryCount ? "retry-limit" : "expiry",
diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts
index 21727e8f01e..62fd743998b 100644
--- a/src/agents/subagent-registry-queries.ts
+++ b/src/agents/subagent-registry-queries.ts
@@ -113,6 +113,59 @@ export function countActiveDescendantRunsFromRuns(
return count;
}
+function countPendingDescendantRunsInternal(
+ runs: Map,
+ rootSessionKey: string,
+ excludeRunId?: string,
+): number {
+ const root = rootSessionKey.trim();
+ if (!root) {
+ return 0;
+ }
+ const excludedRunId = excludeRunId?.trim();
+ const pending = [root];
+ const visited = new Set([root]);
+ let count = 0;
+ for (let index = 0; index < pending.length; index += 1) {
+ const requester = pending[index];
+ if (!requester) {
+ continue;
+ }
+ for (const [runId, entry] of runs.entries()) {
+ if (entry.requesterSessionKey !== requester) {
+ continue;
+ }
+ const runEnded = typeof entry.endedAt === "number";
+ const cleanupCompleted = typeof entry.cleanupCompletedAt === "number";
+ if ((!runEnded || !cleanupCompleted) && runId !== excludedRunId) {
+ count += 1;
+ }
+ const childKey = entry.childSessionKey.trim();
+ if (!childKey || visited.has(childKey)) {
+ continue;
+ }
+ visited.add(childKey);
+ pending.push(childKey);
+ }
+ }
+ return count;
+}
+
+export function countPendingDescendantRunsFromRuns(
+ runs: Map,
+ rootSessionKey: string,
+): number {
+ return countPendingDescendantRunsInternal(runs, rootSessionKey);
+}
+
+export function countPendingDescendantRunsExcludingRunFromRuns(
+ runs: Map,
+ rootSessionKey: string,
+ excludeRunId: string,
+): number {
+ return countPendingDescendantRunsInternal(runs, rootSessionKey, excludeRunId);
+}
+
export function listDescendantRunsForRequesterFromRuns(
runs: Map,
rootSessionKey: string,
diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts
index 498b38aaedc..1ad4bf002b6 100644
--- a/src/agents/subagent-registry.announce-loop-guard.test.ts
+++ b/src/agents/subagent-registry.announce-loop-guard.test.ts
@@ -156,6 +156,41 @@ describe("announce loop guard (#18264)", () => {
expect(stored?.cleanupCompletedAt).toBeDefined();
});
+ test("expired completion-message entries are still resumed for announce", async () => {
+ announceFn.mockReset();
+ announceFn.mockResolvedValueOnce(true);
+ registry.resetSubagentRegistryForTests();
+
+ const now = Date.now();
+ const runId = "test-expired-completion-message";
+ loadSubagentRegistryFromDisk.mockReturnValue(
+ new Map([
+ [
+ runId,
+ {
+ runId,
+ childSessionKey: "agent:main:subagent:child-1",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "agent:main:main",
+ task: "completion announce after long descendants",
+ cleanup: "keep" as const,
+ createdAt: now - 20 * 60_000,
+ startedAt: now - 19 * 60_000,
+ endedAt: now - 10 * 60_000,
+ cleanupHandled: false,
+ expectsCompletionMessage: true,
+ },
+ ],
+ ]),
+ );
+
+ registry.initSubagentRegistry();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(announceFn).toHaveBeenCalledTimes(1);
+ });
+
test("announce rejection resets cleanupHandled so retries can resume", async () => {
announceFn.mockReset();
announceFn.mockRejectedValueOnce(new Error("announce failed"));
diff --git a/src/agents/subagent-registry.nested.e2e.test.ts b/src/agents/subagent-registry.nested.e2e.test.ts
index 9724d1bf780..7da5d951999 100644
--- a/src/agents/subagent-registry.nested.e2e.test.ts
+++ b/src/agents/subagent-registry.nested.e2e.test.ts
@@ -162,4 +162,88 @@ describe("subagent registry nested agent tracking", () => {
expect(countActiveDescendantRuns("agent:main:main")).toBe(1);
expect(countActiveDescendantRuns("agent:main:subagent:orch-ended")).toBe(1);
});
+
+ it("countPendingDescendantRuns includes ended descendants until cleanup completes", async () => {
+ const { addSubagentRunForTests, countPendingDescendantRuns } = subagentRegistry;
+
+ addSubagentRunForTests({
+ runId: "run-parent-ended-pending",
+ childSessionKey: "agent:main:subagent:orch-pending",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "orchestrate",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: false,
+ cleanupCompletedAt: undefined,
+ });
+ addSubagentRunForTests({
+ runId: "run-leaf-ended-pending",
+ childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf",
+ requesterSessionKey: "agent:main:subagent:orch-pending",
+ requesterDisplayKey: "orch-pending",
+ task: "leaf",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: true,
+ cleanupCompletedAt: undefined,
+ });
+
+ expect(countPendingDescendantRuns("agent:main:main")).toBe(2);
+ expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
+
+ addSubagentRunForTests({
+ runId: "run-leaf-completed",
+ childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf-completed",
+ requesterSessionKey: "agent:main:subagent:orch-pending",
+ requesterDisplayKey: "orch-pending",
+ task: "leaf complete",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: true,
+ cleanupCompletedAt: 3,
+ });
+ expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
+ });
+
+ it("countPendingDescendantRunsExcludingRun ignores only the active announce run", async () => {
+ const { addSubagentRunForTests, countPendingDescendantRunsExcludingRun } = subagentRegistry;
+
+ addSubagentRunForTests({
+ runId: "run-self",
+ childSessionKey: "agent:main:subagent:worker",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "self",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: false,
+ cleanupCompletedAt: undefined,
+ });
+
+ addSubagentRunForTests({
+ runId: "run-sibling",
+ childSessionKey: "agent:main:subagent:sibling",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "sibling",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: false,
+ cleanupCompletedAt: undefined,
+ });
+
+ expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-self")).toBe(1);
+ expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-sibling")).toBe(1);
+ });
});
diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts
index 6a7e86100c6..2f200535c4e 100644
--- a/src/agents/subagent-registry.steer-restart.test.ts
+++ b/src/agents/subagent-registry.steer-restart.test.ts
@@ -537,7 +537,7 @@ describe("subagent registry steer restarts", () => {
});
});
- it("emits subagent_ended when completion cleanup expires with active descendants", async () => {
+ it("keeps completion cleanup pending while descendants are still active", async () => {
announceSpy.mockResolvedValue(false);
mod.registerSubagentRun({
@@ -574,10 +574,11 @@ describe("subagent registry steer restarts", () => {
const event = call[0] as { runId?: string; reason?: string };
return event.runId === "run-parent-expiry" && event.reason === "subagent-complete";
});
- expect(parentHookCall).toBeDefined();
+ expect(parentHookCall).toBeUndefined();
const parent = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-parent-expiry");
- expect(parent?.cleanupCompletedAt).toBeTypeOf("number");
+ expect(parent?.cleanupCompletedAt).toBeUndefined();
+ expect(parent?.cleanupHandled).toBe(false);
});
});
diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts
index eb8f6a287d5..900aa4752d9 100644
--- a/src/agents/subagent-registry.ts
+++ b/src/agents/subagent-registry.ts
@@ -32,6 +32,8 @@ import {
import {
countActiveDescendantRunsFromRuns,
countActiveRunsForSessionFromRuns,
+ countPendingDescendantRunsExcludingRunFromRuns,
+ countPendingDescendantRunsFromRuns,
findRunIdsByChildSessionKeyFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForRequesterFromRuns,
@@ -63,10 +65,15 @@ const MAX_ANNOUNCE_RETRY_DELAY_MS = 8_000;
*/
const MAX_ANNOUNCE_RETRY_COUNT = 3;
/**
- * Announce entries older than this are force-expired even if delivery never
- * succeeded. Guards against stale registry entries surviving gateway restarts.
+ * Non-completion announce entries older than this are force-expired even if
+ * delivery never succeeded.
*/
const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes
+/**
+ * Completion-message flows can wait for descendants to finish, but this hard
+ * cap prevents indefinite pending state when descendants never fully settle.
+ */
+const ANNOUNCE_COMPLETION_HARD_EXPIRY_MS = 30 * 60_000; // 30 minutes
type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id";
/**
* Embedded runs can emit transient lifecycle `error` events while provider/model
@@ -445,7 +452,11 @@ function resumeSubagentRun(runId: string) {
persistSubagentRuns();
return;
}
- if (typeof entry.endedAt === "number" && Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS) {
+ if (
+ entry.expectsCompletionMessage !== true &&
+ typeof entry.endedAt === "number" &&
+ Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS
+ ) {
logAnnounceGiveUp(entry, "expiry");
entry.cleanupCompletedAt = Date.now();
persistSubagentRuns();
@@ -462,6 +473,7 @@ function resumeSubagentRun(runId: string) {
) {
const waitMs = Math.max(1, earliestRetryAt - now);
setTimeout(() => {
+ resumedRuns.delete(runId);
resumeSubagentRun(runId);
}, waitMs).unref?.();
resumedRuns.add(runId);
@@ -709,8 +721,10 @@ async function finalizeSubagentCleanup(
const deferredDecision = resolveDeferredCleanupDecision({
entry,
now,
- activeDescendantRuns: Math.max(0, countActiveDescendantRuns(entry.childSessionKey)),
+ // Defer until descendants are fully settled, including post-end cleanup.
+ activeDescendantRuns: Math.max(0, countPendingDescendantRuns(entry.childSessionKey)),
announceExpiryMs: ANNOUNCE_EXPIRY_MS,
+ announceCompletionHardExpiryMs: ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
maxAnnounceRetryCount: MAX_ANNOUNCE_RETRY_COUNT,
deferDescendantDelayMs: MIN_ANNOUNCE_RETRY_DELAY_MS,
resolveAnnounceRetryDelayMs,
@@ -753,6 +767,7 @@ async function finalizeSubagentCleanup(
// Applies to both keep/delete cleanup modes so delete-runs are only removed
// after a successful announce (or terminal give-up).
entry.cleanupHandled = false;
+ // Clear the in-flight resume marker so the scheduled retry can run again.
resumedRuns.delete(runId);
persistSubagentRuns();
if (deferredDecision.resumeDelayMs == null) {
@@ -815,9 +830,10 @@ function retryDeferredCompletedAnnounces(excludeRunId?: string) {
if (suppressAnnounceForSteerRestart(entry)) {
continue;
}
- // Force-expire announces that have been pending too long (#18264).
+ // Force-expire stale non-completion announces; completion-message flows can
+ // stay pending while descendants run for a long time.
const endedAgo = now - (entry.endedAt ?? now);
- if (endedAgo > ANNOUNCE_EXPIRY_MS) {
+ if (entry.expectsCompletionMessage !== true && endedAgo > ANNOUNCE_EXPIRY_MS) {
logAnnounceGiveUp(entry, "expiry");
entry.cleanupCompletedAt = now;
persistSubagentRuns();
@@ -1214,6 +1230,24 @@ export function countActiveDescendantRuns(rootSessionKey: string): number {
);
}
+export function countPendingDescendantRuns(rootSessionKey: string): number {
+ return countPendingDescendantRunsFromRuns(
+ getSubagentRunsSnapshotForRead(subagentRuns),
+ rootSessionKey,
+ );
+}
+
+export function countPendingDescendantRunsExcludingRun(
+ rootSessionKey: string,
+ excludeRunId: string,
+): number {
+ return countPendingDescendantRunsExcludingRunFromRuns(
+ getSubagentRunsSnapshotForRead(subagentRuns),
+ rootSessionKey,
+ excludeRunId,
+ );
+}
+
export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] {
return listDescendantRunsForRequesterFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts
index 9b0b75ce857..bd52e597b28 100644
--- a/src/agents/tools/subagents-tool.ts
+++ b/src/agents/tools/subagents-tool.ts
@@ -31,6 +31,7 @@ import { optionalStringEnum } from "../schema/typebox.js";
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
clearSubagentRunSteerRestart,
+ countPendingDescendantRuns,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
@@ -70,7 +71,10 @@ type ResolvedRequesterKey = {
callerIsSubagent: boolean;
};
-function resolveRunStatus(entry: SubagentRunRecord) {
+function resolveRunStatus(entry: SubagentRunRecord, options?: { hasPendingDescendants?: boolean }) {
+ if (options?.hasPendingDescendants) {
+ return "active";
+ }
if (!entry.endedAt) {
return "running";
}
@@ -365,6 +369,16 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
const recentCutoff = now - recentMinutes * 60_000;
const cache = new Map>();
+ const pendingDescendantCache = new Map();
+ const hasPendingDescendants = (sessionKey: string) => {
+ if (pendingDescendantCache.has(sessionKey)) {
+ return pendingDescendantCache.get(sessionKey) === true;
+ }
+ const hasPending = countPendingDescendantRuns(sessionKey) > 0;
+ pendingDescendantCache.set(sessionKey, hasPending);
+ return hasPending;
+ };
+
let index = 1;
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
const sessionEntry = resolveSessionEntryForKey({
@@ -374,7 +388,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
- const status = resolveRunStatus(entry);
+ const status = resolveRunStatus(entry, {
+ hasPendingDescendants: hasPendingDescendants(entry.childSessionKey),
+ });
const runtime = formatDurationCompact(runtimeMs);
const label = truncateLine(resolveSubagentLabel(entry), 48);
const task = truncateLine(entry.task.trim(), 72);
@@ -396,10 +412,15 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
};
const active = runs
- .filter((entry) => !entry.endedAt)
+ .filter((entry) => !entry.endedAt || hasPendingDescendants(entry.childSessionKey))
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
const recent = runs
- .filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff)
+ .filter(
+ (entry) =>
+ !!entry.endedAt &&
+ !hasPendingDescendants(entry.childSessionKey) &&
+ (entry.endedAt ?? 0) >= recentCutoff,
+ )
.map((entry) =>
buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
);
diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts
index 46971191dc1..8a9941008d7 100644
--- a/src/auto-reply/reply/inbound-meta.test.ts
+++ b/src/auto-reply/reply/inbound-meta.test.ts
@@ -18,6 +18,14 @@ function parseConversationInfoPayload(text: string): Record {
return JSON.parse(match[1]) as Record;
}
+function parseSenderInfoPayload(text: string): Record {
+ const match = text.match(/Sender \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
+ if (!match?.[1]) {
+ throw new Error("missing sender info json block");
+ }
+ return JSON.parse(match[1]) as Record;
+}
+
describe("buildInboundMetaSystemPrompt", () => {
it("includes session-stable routing fields", () => {
const prompt = buildInboundMetaSystemPrompt({
@@ -147,6 +155,29 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["sender"]).toBe("+15551234567");
});
+ it("prefers SenderName in conversation info sender identity", () => {
+ const text = buildInboundUserContextPrefix({
+ ChatType: "group",
+ SenderName: " Tyler ",
+ SenderId: " +15551234567 ",
+ } as TemplateContext);
+
+ const conversationInfo = parseConversationInfoPayload(text);
+ expect(conversationInfo["sender"]).toBe("Tyler");
+ });
+
+ it("includes sender metadata block for direct chats", () => {
+ const text = buildInboundUserContextPrefix({
+ ChatType: "direct",
+ SenderName: "Tyler",
+ SenderId: "+15551234567",
+ } as TemplateContext);
+
+ const senderInfo = parseSenderInfoPayload(text);
+ expect(senderInfo["label"]).toBe("Tyler (+15551234567)");
+ expect(senderInfo["id"]).toBe("+15551234567");
+ });
+
it("includes formatted timestamp in conversation info when provided", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
@@ -187,7 +218,7 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["message_id"]).toBe("msg-123");
});
- it("includes message_id_full when it differs from message_id", () => {
+ it("prefers MessageSid when both MessageSid and MessageSidFull are present", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
MessageSid: "short-id",
@@ -196,18 +227,18 @@ describe("buildInboundUserContextPrefix", () => {
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["message_id"]).toBe("short-id");
- expect(conversationInfo["message_id_full"]).toBe("full-provider-message-id");
+ expect(conversationInfo["message_id_full"]).toBeUndefined();
});
- it("omits message_id_full when it matches message_id", () => {
+ it("falls back to MessageSidFull when MessageSid is missing", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
- MessageSid: "same-id",
- MessageSidFull: "same-id",
+ MessageSid: " ",
+ MessageSidFull: "full-provider-message-id",
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
- expect(conversationInfo["message_id"]).toBe("same-id");
+ expect(conversationInfo["message_id"]).toBe("full-provider-message-id");
expect(conversationInfo["message_id_full"]).toBeUndefined();
});
diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts
index 99296ef3f67..eea956785ae 100644
--- a/src/auto-reply/reply/inbound-meta.ts
+++ b/src/auto-reply/reply/inbound-meta.ts
@@ -88,21 +88,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
const messageId = safeTrim(ctx.MessageSid);
const messageIdFull = safeTrim(ctx.MessageSidFull);
+ const resolvedMessageId = messageId ?? messageIdFull;
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
const conversationInfo = {
- message_id: isDirect ? undefined : messageId,
- message_id_full: isDirect
- ? undefined
- : messageIdFull && messageIdFull !== messageId
- ? messageIdFull
- : undefined,
+ message_id: isDirect ? undefined : resolvedMessageId,
reply_to_id: isDirect ? undefined : safeTrim(ctx.ReplyToId),
sender_id: isDirect ? undefined : safeTrim(ctx.SenderId),
conversation_label: isDirect ? undefined : safeTrim(ctx.ConversationLabel),
sender: isDirect
? undefined
- : (safeTrim(ctx.SenderE164) ?? safeTrim(ctx.SenderId) ?? safeTrim(ctx.SenderUsername)),
+ : (safeTrim(ctx.SenderName) ??
+ safeTrim(ctx.SenderE164) ??
+ safeTrim(ctx.SenderId) ??
+ safeTrim(ctx.SenderUsername)),
timestamp: timestampStr,
group_subject: safeTrim(ctx.GroupSubject),
group_channel: safeTrim(ctx.GroupChannel),
@@ -131,20 +130,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
);
}
- const senderInfo = isDirect
- ? undefined
- : {
- label: resolveSenderLabel({
- name: safeTrim(ctx.SenderName),
- username: safeTrim(ctx.SenderUsername),
- tag: safeTrim(ctx.SenderTag),
- e164: safeTrim(ctx.SenderE164),
- }),
- name: safeTrim(ctx.SenderName),
- username: safeTrim(ctx.SenderUsername),
- tag: safeTrim(ctx.SenderTag),
- e164: safeTrim(ctx.SenderE164),
- };
+ const senderInfo = {
+ label: resolveSenderLabel({
+ name: safeTrim(ctx.SenderName),
+ username: safeTrim(ctx.SenderUsername),
+ tag: safeTrim(ctx.SenderTag),
+ e164: safeTrim(ctx.SenderE164),
+ id: safeTrim(ctx.SenderId),
+ }),
+ id: safeTrim(ctx.SenderId),
+ name: safeTrim(ctx.SenderName),
+ username: safeTrim(ctx.SenderUsername),
+ tag: safeTrim(ctx.SenderTag),
+ e164: safeTrim(ctx.SenderE164),
+ };
if (senderInfo?.label) {
blocks.push(
["Sender (untrusted metadata):", "```json", JSON.stringify(senderInfo, null, 2), "```"].join(
diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts
index bb376bded4b..7df42766cb2 100644
--- a/src/gateway/control-ui.http.test.ts
+++ b/src/gateway/control-ui.http.test.ts
@@ -42,7 +42,7 @@ describe("handleControlUiHttpRequest", () => {
function runControlUiRequest(params: {
url: string;
- method: "GET" | "HEAD";
+ method: "GET" | "HEAD" | "POST";
rootPath: string;
basePath?: string;
}) {
@@ -356,6 +356,36 @@ describe("handleControlUiHttpRequest", () => {
});
});
+ it("falls through POST requests when basePath is empty", async () => {
+ await withControlUiRoot({
+ fn: async (tmp) => {
+ const { handled, end } = runControlUiRequest({
+ url: "/webhook/bluebubbles",
+ method: "POST",
+ rootPath: tmp,
+ });
+ expect(handled).toBe(false);
+ expect(end).not.toHaveBeenCalled();
+ },
+ });
+ });
+
+ it("returns 405 for POST requests under configured basePath", async () => {
+ await withControlUiRoot({
+ fn: async (tmp) => {
+ const { handled, res, end } = runControlUiRequest({
+ url: "/openclaw/",
+ method: "POST",
+ rootPath: tmp,
+ basePath: "/openclaw",
+ });
+ expect(handled).toBe(true);
+ expect(res.statusCode).toBe(405);
+ expect(end).toHaveBeenCalledWith("Method Not Allowed");
+ },
+ });
+ });
+
it("rejects absolute-path escape attempts under basePath routes", async () => {
await withBasePathRootFixture({
siblingDir: "ui-secrets",
diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts
index e410eb23d17..ddfc70418e3 100644
--- a/src/gateway/control-ui.ts
+++ b/src/gateway/control-ui.ts
@@ -275,13 +275,6 @@ export function handleControlUiHttpRequest(
if (!urlRaw) {
return false;
}
- if (req.method !== "GET" && req.method !== "HEAD") {
- res.statusCode = 405;
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
- res.end("Method Not Allowed");
- return true;
- }
-
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts?.basePath);
const pathname = url.pathname;
@@ -315,6 +308,19 @@ export function handleControlUiHttpRequest(
}
}
+ // Method guard must run AFTER path checks so that POST requests to non-UI
+ // paths (channel webhooks etc.) fall through to later handlers. When no
+ // basePath is configured the SPA catch-all would otherwise 405 every POST.
+ if (req.method !== "GET" && req.method !== "HEAD") {
+ if (!basePath) {
+ return false;
+ }
+ res.statusCode = 405;
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.end("Method Not Allowed");
+ return true;
+ }
+
applyControlUiSecurityHeaders(res);
const bootstrapConfigPath = basePath
From cb491dfde5cbd3c302c71dfd7cc42f8576946a25 Mon Sep 17 00:00:00 2001
From: jamtujest <76851584+jamtujest@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:06:10 +0100
Subject: [PATCH 048/861] feat(docker): add opt-in sandbox support for Docker
deployments (#29974)
* feat(docker): add opt-in sandbox support for Docker deployments
Enable Docker-based sandbox isolation via OPENCLAW_SANDBOX=1 env var
in docker-setup.sh. This is a prerequisite for agents.defaults.sandbox
to function in any Docker deployment (self-hosted, Hostinger, DigitalOcean).
Changes:
- Dockerfile: add OPENCLAW_INSTALL_DOCKER_CLI build arg (~50MB, opt-in)
- docker-compose.yml: add commented-out docker.sock mount with docs
- docker-setup.sh: auto-detect Docker socket, inject mount, detect GID,
build sandbox image, configure sandbox defaults, add group_add
All changes are opt-in. Zero impact on existing deployments.
Usage: OPENCLAW_SANDBOX=1 ./docker-setup.sh
Closes #29933
Related: #7575, #7827, #28401, #10361, #12505, #28326
Co-Authored-By: Claude Opus 4.6
* fix: address code review feedback on sandbox support
- Persist OPENCLAW_SANDBOX, DOCKER_GID, OPENCLAW_INSTALL_DOCKER_CLI
to .env via upsert_env so group_add survives re-runs
- Show config set errors instead of swallowing them silently;
report partial failure when sandbox config is incomplete
- Warn when Dockerfile.sandbox is missing but sandbox config
is still applied (sandbox image won't exist)
- Fix non-canonical whitespace in apt sources.list entry
by using printf instead of echo with line continuation
Co-Authored-By: Claude Opus 4.6
* fix: remove `local` outside function and guard sandbox behind Docker CLI check
- Remove `local` keyword from top-level `sandbox_config_ok` assignment
which caused script exit under `set -euo pipefail` (bash `local`
outside a function is an error)
- Add Docker CLI prerequisite check for pre-built (non-local) images:
runs `docker --version` inside the container and skips sandbox setup
with a clear warning if the CLI is missing
- Split sandbox block so config is only applied after prerequisites pass
Co-Authored-By: Claude Opus 4.6
* fix: defer docker.sock mount until sandbox prerequisites pass
Move Docker socket mounting from the early setup phase (before image
build/pull) to a dedicated compose overlay created only after:
1. Docker CLI is verified inside the container image
2. /var/run/docker.sock exists on the host
Previously the socket was mounted optimistically at startup, leaving
the host Docker daemon exposed even when sandbox setup was later
skipped due to missing Docker CLI. Now the gateway starts without
the socket, and a docker-compose.sandbox.yml overlay is generated
only when all prerequisites pass. The gateway restart at the end of
sandbox setup picks up both the socket mount and sandbox config.
Also moves group_add from write_extra_compose() into the sandbox
overlay, keeping all sandbox-specific compose configuration together.
Co-Authored-By: Claude Opus 4.6
* docs(docker): fix sandbox docs URL in setup output
* Docker: harden sandbox setup fallback behavior
* Tests: cover docker-setup sandbox edge paths
* Docker: roll back sandbox mode on partial config failure
* Tests: assert sandbox mode rollback on partial setup
* Docs: document Docker sandbox bootstrap env controls
* Changelog: credit Docker sandbox bootstrap hardening
* Update CHANGELOG.md
* Docker: verify Docker apt signing key fingerprint
* Docker: avoid sandbox overlay deps during policy writes
* Tests: assert no-deps sandbox rollback gateway recreate
* Docs: mention OPENCLAW_INSTALL_DOCKER_CLI in Docker env vars
---------
Co-authored-by: Jakub Karwowski
Co-authored-by: Claude Opus 4.6
Co-authored-by: Vincent Koc
---
CHANGELOG.md | 1 +
Dockerfile | 32 +++++++
docker-compose.yml | 8 ++
docker-setup.sh | 159 +++++++++++++++++++++++++++++++++++
docs/install/docker.md | 35 ++++++++
src/docker-setup.e2e.test.ts | 113 +++++++++++++++++++++++++
6 files changed, 348 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e251c9c357..cebf72c5021 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
+- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
diff --git a/Dockerfile b/Dockerfile
index 8753631a7cf..40a5fbc2d8e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -57,6 +57,38 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
+# Optionally install Docker CLI for sandbox container management.
+# Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ...
+# Adds ~50MB. Only the CLI is installed — no Docker daemon.
+# Required for agents.defaults.sandbox to function in Docker deployments.
+ARG OPENCLAW_INSTALL_DOCKER_CLI=""
+ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
+RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
+ apt-get update && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ ca-certificates curl gnupg && \
+ install -m 0755 -d /etc/apt/keyrings && \
+ # Verify Docker apt signing key fingerprint before trusting it as a root key.
+ # Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
+ curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
+ expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
+ actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
+ if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
+ echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-})" >&2; \
+ exit 1; \
+ fi && \
+ gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg.asc && \
+ rm -f /tmp/docker.gpg.asc && \
+ chmod a+r /etc/apt/keyrings/docker.gpg && \
+ printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\n' \
+ "$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
+ apt-get update && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ docker-ce-cli docker-compose-plugin && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
+ fi
+
USER node
COPY --chown=node:node . .
# Normalize copied plugin/agent paths so plugin safety checks do not reject
diff --git a/docker-compose.yml b/docker-compose.yml
index 8bc1d390b81..a17558157f7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,14 @@ services:
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
+ ## Uncomment the lines below to enable sandbox isolation
+ ## (agents.defaults.sandbox). Requires Docker CLI in the image
+ ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
+ ## docker-setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
+ ## Set DOCKER_GID to the host's docker group GID (run: stat -c '%g' /var/run/docker.sock).
+ # - /var/run/docker.sock:/var/run/docker.sock
+ # group_add:
+ # - "${DOCKER_GID:-999}"
ports:
- "${OPENCLAW_GATEWAY_PORT:-18789}:18789"
- "${OPENCLAW_BRIDGE_PORT:-18790}:18790"
diff --git a/docker-setup.sh b/docker-setup.sh
index 71ae84d2afa..ce5e6a08f3d 100755
--- a/docker-setup.sh
+++ b/docker-setup.sh
@@ -7,6 +7,9 @@ EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
+RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
+SANDBOX_ENABLED=""
+DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
fail() {
echo "ERROR: $*" >&2
@@ -20,6 +23,15 @@ require_cmd() {
fi
}
+is_truthy_value() {
+ local raw="${1:-}"
+ raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
+ case "$raw" in
+ 1 | true | yes | on) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
read_config_gateway_token() {
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
if [[ ! -f "$config_path" ]]; then
@@ -144,6 +156,16 @@ if ! docker compose version >/dev/null 2>&1; then
exit 1
fi
+if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then
+ DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}"
+fi
+if [[ -z "$DOCKER_SOCKET_PATH" ]]; then
+ DOCKER_SOCKET_PATH="/var/run/docker.sock"
+fi
+if is_truthy_value "$RAW_SANDBOX_SETTING"; then
+ SANDBOX_ENABLED="1"
+fi
+
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
@@ -159,6 +181,9 @@ fi
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
fi
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
+fi
mkdir -p "$OPENCLAW_CONFIG_DIR"
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
@@ -178,6 +203,15 @@ export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
+export OPENCLAW_SANDBOX="$SANDBOX_ENABLED"
+export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH"
+
+# Detect Docker socket GID for sandbox group_add.
+DOCKER_GID=""
+if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
+ DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
+fi
+export DOCKER_GID
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
@@ -255,6 +289,14 @@ YAML
fi
}
+# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
+# Docker socket mount is deferred until sandbox prerequisites are verified.
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
+ export OPENCLAW_INSTALL_DOCKER_CLI=1
+ fi
+fi
+
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
@@ -279,6 +321,9 @@ fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
+# Keep a base compose arg set without sandbox overlay so rollback paths can
+# force a known-safe gateway service definition (no docker.sock mount).
+BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
@@ -333,12 +378,17 @@ upsert_env "$ENV_FILE" \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
OPENCLAW_DOCKER_APT_PACKAGES \
+ OPENCLAW_SANDBOX \
+ OPENCLAW_DOCKER_SOCKET \
+ DOCKER_GID \
+ OPENCLAW_INSTALL_DOCKER_CLI \
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"
docker build \
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
+ --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
@@ -399,6 +449,115 @@ echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
+# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ echo ""
+ echo "==> Sandbox setup"
+
+ # Build sandbox image if Dockerfile.sandbox exists.
+ if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
+ echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
+ docker build \
+ -t "openclaw-sandbox:bookworm-slim" \
+ -f "$ROOT_DIR/Dockerfile.sandbox" \
+ "$ROOT_DIR"
+ else
+ echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
+ echo " Sandbox config will be applied but no sandbox image will be built." >&2
+ echo " Agent exec may fail if the configured sandbox image does not exist." >&2
+ fi
+
+ # Defense-in-depth: verify Docker CLI in the running image before enabling
+ # sandbox. This avoids claiming sandbox is enabled when the image cannot
+ # launch sandbox containers.
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
+ echo "WARNING: Docker CLI not found inside the container image." >&2
+ echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
+ echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
+ SANDBOX_ENABLED=""
+ fi
+fi
+
+# Apply sandbox config only if prerequisites are met.
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ # Mount Docker socket via a dedicated compose overlay. This overlay is
+ # created only after sandbox prerequisites pass, so the socket is never
+ # exposed when sandbox cannot actually run.
+ if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
+ SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
+ cat >"$SANDBOX_COMPOSE_FILE" <>"$SANDBOX_COMPOSE_FILE" < Sandbox: added Docker socket mount"
+ else
+ echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
+ echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
+ SANDBOX_ENABLED=""
+ fi
+fi
+
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ # Enable sandbox in OpenClaw config.
+ sandbox_config_ok=true
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
+ echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2
+ sandbox_config_ok=false
+ fi
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.scope "agent" >/dev/null; then
+ echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2
+ sandbox_config_ok=false
+ fi
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
+ echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
+ sandbox_config_ok=false
+ fi
+
+ if [[ "$sandbox_config_ok" == true ]]; then
+ echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
+ echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
+ # Restart gateway with sandbox compose overlay to pick up socket mount + config.
+ docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
+ else
+ echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
+ echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
+ if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.mode "off" >/dev/null; then
+ echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
+ else
+ echo "Sandbox mode rolled back to off due to partial sandbox config failure."
+ fi
+ if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; then
+ rm -f "$SANDBOX_COMPOSE_FILE"
+ fi
+ # Ensure gateway service definition is reset without sandbox overlay mount.
+ docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
+ fi
+else
+ # Keep reruns deterministic: if sandbox is not active for this run, reset
+ # persisted sandbox mode so future execs do not require docker.sock by stale
+ # config alone.
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
+ config set agents.defaults.sandbox.mode "off" >/dev/null; then
+ echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
+ fi
+ if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
+ rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
+ fi
+fi
+
echo ""
echo "Gateway running with host port mapping."
echo "Access from tailnet devices via the host's tailnet IP."
diff --git a/docs/install/docker.md b/docs/install/docker.md
index 6bab52cfc4e..42ce7a08d4d 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -59,6 +59,9 @@ Optional env vars:
- `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during build
- `OPENCLAW_EXTRA_MOUNTS` — add extra host bind mounts
- `OPENCLAW_HOME_VOLUME` — persist `/home/node` in a named volume
+- `OPENCLAW_SANDBOX` — opt in to Docker gateway sandbox bootstrap. Only explicit truthy values enable it: `1`, `true`, `yes`, `on`
+- `OPENCLAW_INSTALL_DOCKER_CLI` — build arg passthrough for local image builds (`1` installs Docker CLI in the image). `docker-setup.sh` sets this automatically when `OPENCLAW_SANDBOX=1` for local builds.
+- `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`)
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
`ws://` targets for CLI/onboarding client paths (default is loopback-only)
@@ -68,6 +71,38 @@ After it finishes:
- Paste the token into the Control UI (Settings → token).
- Need the URL again? Run `docker compose run --rm openclaw-cli dashboard --no-open`.
+### Enable agent sandbox for Docker gateway (opt-in)
+
+`docker-setup.sh` can also bootstrap `agents.defaults.sandbox.*` for Docker
+deployments.
+
+Enable with:
+
+```bash
+export OPENCLAW_SANDBOX=1
+./docker-setup.sh
+```
+
+Custom socket path (for example rootless Docker):
+
+```bash
+export OPENCLAW_SANDBOX=1
+export OPENCLAW_DOCKER_SOCKET=/run/user/1000/docker.sock
+./docker-setup.sh
+```
+
+Notes:
+
+- The script mounts `docker.sock` only after sandbox prerequisites pass.
+- If sandbox setup cannot be completed, the script resets
+ `agents.defaults.sandbox.mode` to `off` to avoid stale/broken sandbox config
+ on reruns.
+- If `Dockerfile.sandbox` is missing, the script prints a warning and continues;
+ build `openclaw-sandbox:bookworm-slim` with `scripts/sandbox-setup.sh` if
+ needed.
+- For non-local `OPENCLAW_IMAGE` values, the image must already contain Docker
+ CLI support for sandbox execution.
+
### Automation/CI (non-interactive, no TTY noise)
For scripts and CI, disable Compose pseudo-TTY allocation with `-T`:
diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts
index defb5a2120a..df2848f0f67 100644
--- a/src/docker-setup.e2e.test.ts
+++ b/src/docker-setup.e2e.test.ts
@@ -1,5 +1,6 @@
import { spawnSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -18,14 +19,23 @@ async function writeDockerStub(binDir: string, logPath: string) {
const stub = `#!/usr/bin/env bash
set -euo pipefail
log="$DOCKER_STUB_LOG"
+fail_match="\${DOCKER_STUB_FAIL_MATCH:-}"
if [[ "\${1:-}" == "compose" && "\${2:-}" == "version" ]]; then
exit 0
fi
if [[ "\${1:-}" == "build" ]]; then
+ if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then
+ echo "build-fail $*" >>"$log"
+ exit 1
+ fi
echo "build $*" >>"$log"
exit 0
fi
if [[ "\${1:-}" == "compose" ]]; then
+ if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then
+ echo "compose-fail $*" >>"$log"
+ exit 1
+ fi
echo "compose $*" >>"$log"
exit 0
fi
@@ -103,6 +113,30 @@ function runDockerSetup(
});
}
+async function withUnixSocket(socketPath: string, run: () => Promise): Promise {
+ const server = createServer();
+ await new Promise((resolve, reject) => {
+ const onError = (error: Error) => {
+ server.off("listening", onListening);
+ reject(error);
+ };
+ const onListening = () => {
+ server.off("error", onError);
+ resolve();
+ };
+ server.once("error", onError);
+ server.once("listening", onListening);
+ server.listen(socketPath);
+ });
+
+ try {
+ return await run();
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ await rm(socketPath, { force: true });
+ }
+}
+
function resolveBashForCompatCheck(): string | null {
for (const candidate of ["/bin/bash", "bash"]) {
const probe = spawnSync(candidate, ["-c", "exit 0"], { encoding: "utf8" });
@@ -216,6 +250,85 @@ describe("docker-setup.sh", () => {
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123");
});
+ it("treats OPENCLAW_SANDBOX=0 as disabled", async () => {
+ const activeSandbox = requireSandbox(sandbox);
+ await writeFile(activeSandbox.logPath, "");
+
+ const result = runDockerSetup(activeSandbox, {
+ OPENCLAW_SANDBOX: "0",
+ });
+
+ expect(result.status).toBe(0);
+ const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
+ expect(envFile).toContain("OPENCLAW_SANDBOX=");
+
+ const log = await readFile(activeSandbox.logPath, "utf8");
+ expect(log).toContain("--build-arg OPENCLAW_INSTALL_DOCKER_CLI=");
+ expect(log).not.toContain("--build-arg OPENCLAW_INSTALL_DOCKER_CLI=1");
+ expect(log).toContain("config set agents.defaults.sandbox.mode off");
+ });
+
+ it("resets stale sandbox mode and overlay when sandbox is not active", async () => {
+ const activeSandbox = requireSandbox(sandbox);
+ await writeFile(activeSandbox.logPath, "");
+ await writeFile(
+ join(activeSandbox.rootDir, "docker-compose.sandbox.yml"),
+ "services:\n openclaw-gateway:\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n",
+ );
+
+ const result = runDockerSetup(activeSandbox, {
+ OPENCLAW_SANDBOX: "1",
+ DOCKER_STUB_FAIL_MATCH: "--entrypoint docker openclaw-gateway --version",
+ });
+
+ expect(result.status).toBe(0);
+ expect(result.stderr).toContain("Sandbox requires Docker CLI");
+ const log = await readFile(activeSandbox.logPath, "utf8");
+ expect(log).toContain("config set agents.defaults.sandbox.mode off");
+ await expect(stat(join(activeSandbox.rootDir, "docker-compose.sandbox.yml"))).rejects.toThrow();
+ });
+
+ it("skips sandbox gateway restart when sandbox config writes fail", async () => {
+ const activeSandbox = requireSandbox(sandbox);
+ await writeFile(activeSandbox.logPath, "");
+ const socketPath = join(activeSandbox.rootDir, "sandbox.sock");
+
+ await withUnixSocket(socketPath, async () => {
+ const result = runDockerSetup(activeSandbox, {
+ OPENCLAW_SANDBOX: "1",
+ OPENCLAW_DOCKER_SOCKET: socketPath,
+ DOCKER_STUB_FAIL_MATCH: "config set agents.defaults.sandbox.scope",
+ });
+
+ expect(result.status).toBe(0);
+ expect(result.stderr).toContain("Failed to set agents.defaults.sandbox.scope");
+ expect(result.stderr).toContain("Skipping gateway restart to avoid exposing Docker socket");
+
+ const log = await readFile(activeSandbox.logPath, "utf8");
+ const gatewayStarts = log
+ .split("\n")
+ .filter(
+ (line) =>
+ line.includes("compose") &&
+ line.includes(" up -d") &&
+ line.includes("openclaw-gateway"),
+ );
+ expect(gatewayStarts).toHaveLength(2);
+ expect(log).toContain(
+ "run --rm --no-deps openclaw-cli config set agents.defaults.sandbox.mode non-main",
+ );
+ expect(log).toContain("config set agents.defaults.sandbox.mode off");
+ const forceRecreateLine = log
+ .split("\n")
+ .find((line) => line.includes("up -d --force-recreate openclaw-gateway"));
+ expect(forceRecreateLine).toBeDefined();
+ expect(forceRecreateLine).not.toContain("docker-compose.sandbox.yml");
+ await expect(
+ stat(join(activeSandbox.rootDir, "docker-compose.sandbox.yml")),
+ ).rejects.toThrow();
+ });
+ });
+
it("rejects injected multiline OPENCLAW_EXTRA_MOUNTS values", async () => {
const activeSandbox = requireSandbox(sandbox);
From 281494ae52578f856e4942f621ea941d6b2e39af Mon Sep 17 00:00:00 2001
From: Veast <961171432@qq.com>
Date: Mon, 2 Mar 2026 15:08:52 +0800
Subject: [PATCH 049/861] fix(browser): include Chrome stderr and sandbox hint
in CDP startup error (#29355)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(browser): include Chrome stderr and sandbox hint in CDP startup error (#29312)
When Chrome fails to start and CDP times out, the error message previously
contained no diagnostic information, making it impossible to determine why
Chrome couldn't start (e.g. missing --no-sandbox in containers, GPU issues,
shared memory errors).
This change:
- Collects Chrome's stderr output and includes up to 2000 chars in the error
- On Linux, if noSandbox is not set, appends a hint to try browser.noSandbox: true
Closes #29312
* chore(browser): format chrome startup diagnostics
* fix(browser): detach stderr listener after Chrome starts to prevent memory leak
Named the anonymous listener so it can be removed via proc.stderr.off()
once CDP is confirmed reachable. Also clears the stderrChunks array on
success so the buffered data is eligible for GC.
Fixes the unbounded memory growth reported in code review: a long-lived
Chrome process emitting periodic warnings would keep appending to
stderrChunks indefinitely since the listener was never removed.
Addresses review comment from chatgpt-codex-connector on PR #29355.
* changelog: note cdp startup diagnostics improvement
---------
Co-authored-by: Vincent Koc
Co-authored-by: 派尼尔
---
CHANGELOG.md | 1 +
src/browser/chrome.ts | 22 +++++++++++++++++++++-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cebf72c5021..5992a979a60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
+- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts
index 9501d1e4d98..d6dc9990ffd 100644
--- a/src/browser/chrome.ts
+++ b/src/browser/chrome.ts
@@ -285,6 +285,16 @@ export async function launchOpenClawChrome(
}
const proc = spawnOnce();
+
+ // Collect stderr for diagnostics in case Chrome fails to start.
+ // The listener is removed on success to avoid unbounded memory growth
+ // from a long-lived Chrome process that emits periodic warnings.
+ const stderrChunks: Buffer[] = [];
+ const onStderr = (chunk: Buffer) => {
+ stderrChunks.push(chunk);
+ };
+ proc.stderr?.on("data", onStderr);
+
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
@@ -295,16 +305,26 @@ export async function launchOpenClawChrome(
}
if (!(await isChromeReachable(profile.cdpUrl, 500))) {
+ const stderrOutput = Buffer.concat(stderrChunks).toString("utf8").trim();
+ const stderrHint = stderrOutput ? `\nChrome stderr:\n${stderrOutput.slice(0, 2000)}` : "";
+ const sandboxHint =
+ process.platform === "linux" && !resolved.noSandbox
+ ? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config."
+ : "";
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(
- `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`,
+ `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".${sandboxHint}${stderrHint}`,
);
}
+ // Chrome started successfully — detach the stderr listener and release the buffer.
+ proc.stderr?.off("data", onStderr);
+ stderrChunks.length = 0;
+
const pid = proc.pid ?? -1;
log.info(
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
From 7e29d604ba4c4cd248dead0058e09bad633cecde Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 2 Mar 2026 06:40:42 +0000
Subject: [PATCH 050/861] test(agents): dedupe agent and cron test scaffolds
---
src/agents/acp-spawn.test.ts | 133 ++--
.../compaction.identifier-policy.test.ts | 115 +---
...compaction.identifier-preservation.test.ts | 76 +--
src/agents/model-catalog.test.ts | 111 ++--
src/agents/model-selection.test.ts | 122 ++--
...ssing-provider-apikey-from-env-var.test.ts | 333 ++++------
...serves-explicit-reasoning-override.test.ts | 79 +--
src/agents/openclaw-tools.camera.test.ts | 412 +++++-------
...ons-spawn-applies-thinking-default.test.ts | 113 ++--
...sions-spawn-default-timeout-absent.test.ts | 93 ++-
...nts.sessions-spawn-default-timeout.test.ts | 108 ++-
...ded-helpers.sanitizeuserfacingtext.test.ts | 137 ++--
...runner.openai-tool-id-preservation.test.ts | 112 ++--
...ed-runner.sanitize-session-history.test.ts | 249 +++----
.../pi-embedded-runner/run/attempt.test.ts | 84 +--
src/agents/sandbox/fs-bridge.test.ts | 102 ++-
src/agents/session-transcript-repair.test.ts | 249 +++----
src/agents/skills/plugin-skills.test.ts | 153 +++--
...registry.lifecycle-retry-grace.e2e.test.ts | 150 +++--
.../subagent-registry.steer-restart.test.ts | 217 +++---
src/agents/tools/message-tool.test.ts | 453 ++++++-------
src/agents/tools/sessions.test.ts | 187 +++---
src/agents/workspace.test.ts | 68 +-
src/auto-reply/reply/abort.test.ts | 101 ++-
src/auto-reply/reply/acp-projector.test.ts | 622 +++++++-----------
src/auto-reply/reply/commands-acp.test.ts | 495 ++++++--------
.../reply/commands-subagents-focus.test.ts | 111 ++--
.../reply/dispatch-acp-delivery.test.ts | 54 +-
src/auto-reply/reply/dispatch-acp.test.ts | 281 +++-----
src/auto-reply/reply/followup-runner.test.ts | 291 ++++----
.../get-reply.reset-hooks-fallback.test.ts | 146 ++--
src/cron/isolated-agent.mocks.ts | 26 +
...p-recipient-besteffortdeliver-true.test.ts | 191 +++---
.../isolated-agent.subagent-model.test.ts | 182 +++--
.../run.cron-model-override.test.ts | 220 +------
.../run.payload-fallbacks.test.ts | 303 ++-------
.../isolated-agent/run.skill-filter.test.ts | 432 +++---------
src/cron/isolated-agent/run.test-harness.ts | 289 ++++++++
38 files changed, 3114 insertions(+), 4486 deletions(-)
create mode 100644 src/cron/isolated-agent/run.test-harness.ts
diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts
index f722451d0c6..73b5c8bee30 100644
--- a/src/agents/acp-spawn.test.ts
+++ b/src/agents/acp-spawn.test.ts
@@ -2,6 +2,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
+function createDefaultSpawnConfig(): OpenClawConfig {
+ return {
+ acp: {
+ enabled: true,
+ backend: "acpx",
+ allowedAgents: ["codex"],
+ },
+ session: {
+ mainKey: "main",
+ scope: "per-sender",
+ },
+ channels: {
+ discord: {
+ threadBindings: {
+ enabled: true,
+ spawnAcpSessions: true,
+ },
+ },
+ },
+ };
+}
+
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
const sessionBindingCapabilitiesMock = vi.fn();
@@ -12,25 +34,7 @@ const hoisted = vi.hoisted(() => {
const closeSessionMock = vi.fn();
const initializeSessionMock = vi.fn();
const state = {
- cfg: {
- acp: {
- enabled: true,
- backend: "acpx",
- allowedAgents: ["codex"],
- },
- session: {
- mainKey: "main",
- scope: "per-sender",
- },
- channels: {
- discord: {
- threadBindings: {
- enabled: true,
- spawnAcpSessions: true,
- },
- },
- },
- } as OpenClawConfig,
+ cfg: createDefaultSpawnConfig(),
};
return {
callGatewayMock,
@@ -45,6 +49,27 @@ const hoisted = vi.hoisted(() => {
};
});
+function buildSessionBindingServiceMock() {
+ return {
+ touch: vi.fn(),
+ bind(input: unknown) {
+ return hoisted.sessionBindingBindMock(input);
+ },
+ unbind(input: unknown) {
+ return hoisted.sessionBindingUnbindMock(input);
+ },
+ getCapabilities(params: unknown) {
+ return hoisted.sessionBindingCapabilitiesMock(params);
+ },
+ resolveByConversation(ref: unknown) {
+ return hoisted.sessionBindingResolveByConversationMock(ref);
+ },
+ listBySession(targetSessionKey: string) {
+ return hoisted.sessionBindingListBySessionMock(targetSessionKey);
+ },
+ };
+}
+
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -71,20 +96,21 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) =
await importOriginal();
return {
...actual,
- getSessionBindingService: () => ({
- bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
- getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
- listBySession: (targetSessionKey: string) =>
- hoisted.sessionBindingListBySessionMock(targetSessionKey),
- resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
- touch: vi.fn(),
- unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
- }),
+ getSessionBindingService: () => buildSessionBindingServiceMock(),
};
});
const { spawnAcpDirect } = await import("./acp-spawn.js");
+function createSessionBindingCapabilities() {
+ return {
+ adapterAvailable: true,
+ bindSupported: true,
+ unbindSupported: true,
+ placements: ["current", "child"] as const,
+ };
+}
+
function createSessionBinding(overrides?: Partial): SessionBindingRecord {
return {
bindingId: "default:child-thread",
@@ -106,27 +132,21 @@ function createSessionBinding(overrides?: Partial): Sessio
};
}
+function expectResolvedIntroTextInBindMetadata(): void {
+ const callWithMetadata = hoisted.sessionBindingBindMock.mock.calls.find(
+ (call: unknown[]) =>
+ typeof (call[0] as { metadata?: { introText?: unknown } } | undefined)?.metadata
+ ?.introText === "string",
+ );
+ const introText =
+ (callWithMetadata?.[0] as { metadata?: { introText?: string } } | undefined)?.metadata
+ ?.introText ?? "";
+ expect(introText.includes("session ids: pending (available after the first reply)")).toBe(false);
+}
+
describe("spawnAcpDirect", () => {
beforeEach(() => {
- hoisted.state.cfg = {
- acp: {
- enabled: true,
- backend: "acpx",
- allowedAgents: ["codex"],
- },
- session: {
- mainKey: "main",
- scope: "per-sender",
- },
- channels: {
- discord: {
- threadBindings: {
- enabled: true,
- spawnAcpSessions: true,
- },
- },
- },
- } satisfies OpenClawConfig;
+ hoisted.state.cfg = createDefaultSpawnConfig();
hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
const args = argsUnknown as { method?: string };
@@ -186,12 +206,9 @@ describe("spawnAcpDirect", () => {
};
});
- hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
- adapterAvailable: true,
- bindSupported: true,
- unbindSupported: true,
- placements: ["current", "child"],
- });
+ hoisted.sessionBindingCapabilitiesMock
+ .mockReset()
+ .mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingBindMock
.mockReset()
.mockImplementation(
@@ -248,15 +265,7 @@ describe("spawnAcpDirect", () => {
placement: "child",
}),
);
- expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
- expect.objectContaining({
- metadata: expect.objectContaining({
- introText: expect.not.stringContaining(
- "session ids: pending (available after the first reply)",
- ),
- }),
- }),
- );
+ expectResolvedIntroTextInBindMetadata();
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record })
diff --git a/src/agents/compaction.identifier-policy.test.ts b/src/agents/compaction.identifier-policy.test.ts
index ddc6f5ecb8e..23c199236af 100644
--- a/src/agents/compaction.identifier-policy.test.ts
+++ b/src/agents/compaction.identifier-policy.test.ts
@@ -1,89 +1,28 @@
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
-import * as piCodingAgent from "@mariozechner/pi-coding-agent";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js";
-
-vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateSummary: vi.fn(),
- };
-});
-
-const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
-
-function makeMessage(index: number, size = 1200): AgentMessage {
- return {
- role: "user",
- content: `m${index}-${"x".repeat(size)}`,
- timestamp: index,
- };
-}
+import { describe, expect, it } from "vitest";
+import { buildCompactionSummarizationInstructions } from "./compaction.js";
describe("compaction identifier policy", () => {
- const testModel = {
- provider: "anthropic",
- model: "claude-3-opus",
- contextWindow: 200_000,
- } as unknown as NonNullable;
-
- beforeEach(() => {
- mockGenerateSummary.mockReset();
- mockGenerateSummary.mockResolvedValue("summary");
+ it("defaults to strict identifier preservation", () => {
+ const built = buildCompactionSummarizationInstructions();
+ expect(built).toContain("Preserve all opaque identifiers exactly as written");
+ expect(built).toContain("UUIDs");
});
- it("defaults to strict identifier preservation", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
- maxChunkTokens: 8000,
- contextWindow: 200_000,
+ it("can disable identifier preservation with off policy", () => {
+ const built = buildCompactionSummarizationInstructions(undefined, {
+ identifierPolicy: "off",
});
-
- const firstCall = mockGenerateSummary.mock.calls[0];
- expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
- expect(firstCall?.[5]).toContain("UUIDs");
+ expect(built).toBeUndefined();
});
- it("can disable identifier preservation with off policy", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
- maxChunkTokens: 8000,
- contextWindow: 200_000,
- summarizationInstructions: { identifierPolicy: "off" },
+ it("supports custom identifier instructions", () => {
+ const built = buildCompactionSummarizationInstructions(undefined, {
+ identifierPolicy: "custom",
+ identifierInstructions: "Keep ticket IDs unchanged.",
});
- const firstCall = mockGenerateSummary.mock.calls[0];
- expect(firstCall?.[5]).toBeUndefined();
- });
-
- it("supports custom identifier instructions", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
- maxChunkTokens: 8000,
- contextWindow: 200_000,
- summarizationInstructions: {
- identifierPolicy: "custom",
- identifierInstructions: "Keep ticket IDs unchanged.",
- },
- });
-
- const firstCall = mockGenerateSummary.mock.calls[0];
- expect(firstCall?.[5]).toContain("Keep ticket IDs unchanged.");
- expect(firstCall?.[5]).not.toContain("Preserve all opaque identifiers exactly as written");
+ expect(built).toContain("Keep ticket IDs unchanged.");
+ expect(built).not.toContain("Preserve all opaque identifiers exactly as written");
});
it("falls back to strict text when custom policy is missing instructions", () => {
@@ -94,24 +33,10 @@ describe("compaction identifier policy", () => {
expect(built).toContain("Preserve all opaque identifiers exactly as written");
});
- it("avoids duplicate additional-focus headers in split+merge path", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
- maxChunkTokens: 1000,
- contextWindow: 200_000,
- parts: 2,
- minMessagesForSplit: 4,
- customInstructions: "Prioritize customer-visible regressions.",
+ it("keeps custom focus text when identifier policy is off", () => {
+ const built = buildCompactionSummarizationInstructions("Track release blockers.", {
+ identifierPolicy: "off",
});
-
- const mergedCall = mockGenerateSummary.mock.calls.at(-1);
- const instructions = mergedCall?.[5] ?? "";
- expect(instructions).toContain("Merge these partial summaries into a single cohesive summary.");
- expect(instructions).toContain("Prioritize customer-visible regressions.");
- expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1);
+ expect(built).toBe("Additional focus:\nTrack release blockers.");
});
});
diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts
index 810b6307d3f..cdf742e1489 100644
--- a/src/agents/compaction.identifier-preservation.test.ts
+++ b/src/agents/compaction.identifier-preservation.test.ts
@@ -13,6 +13,7 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
});
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
+type SummarizeInStagesInput = Parameters[0];
function makeMessage(index: number, size = 1200): AgentMessage {
return {
@@ -28,58 +29,63 @@ describe("compaction identifier-preservation instructions", () => {
model: "claude-3-opus",
contextWindow: 200_000,
} as unknown as NonNullable;
+ const summarizeBase: Omit = {
+ model: testModel,
+ apiKey: "test-key",
+ reserveTokens: 4000,
+ maxChunkTokens: 8000,
+ contextWindow: 200_000,
+ signal: new AbortController().signal,
+ };
beforeEach(() => {
mockGenerateSummary.mockReset();
mockGenerateSummary.mockResolvedValue("summary");
});
- it("injects identifier-preservation guidance even without custom instructions", async () => {
+ async function runSummary(
+ messageCount: number,
+ overrides: Partial> = {},
+ ) {
await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2)],
- model: testModel,
- apiKey: "test-key",
+ ...summarizeBase,
+ ...overrides,
signal: new AbortController().signal,
- reserveTokens: 4000,
- maxChunkTokens: 8000,
- contextWindow: 200_000,
+ messages: Array.from({ length: messageCount }, (_unused, index) => makeMessage(index + 1)),
});
+ }
+
+ function firstSummaryInstructions() {
+ return mockGenerateSummary.mock.calls[0]?.[5];
+ }
+
+ it("injects identifier-preservation guidance even without custom instructions", async () => {
+ await runSummary(2);
expect(mockGenerateSummary).toHaveBeenCalled();
- const firstCall = mockGenerateSummary.mock.calls[0];
- expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
- expect(firstCall?.[5]).toContain("UUIDs");
- expect(firstCall?.[5]).toContain("IPs");
- expect(firstCall?.[5]).toContain("ports");
+ expect(firstSummaryInstructions()).toContain(
+ "Preserve all opaque identifiers exactly as written",
+ );
+ expect(firstSummaryInstructions()).toContain("UUIDs");
+ expect(firstSummaryInstructions()).toContain("IPs");
+ expect(firstSummaryInstructions()).toContain("ports");
});
it("keeps identifier-preservation guidance when custom instructions are provided", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
- maxChunkTokens: 8000,
- contextWindow: 200_000,
+ await runSummary(2, {
customInstructions: "Focus on release-impacting bugs.",
});
- const firstCall = mockGenerateSummary.mock.calls[0];
- expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
- expect(firstCall?.[5]).toContain("Additional focus:");
- expect(firstCall?.[5]).toContain("Focus on release-impacting bugs.");
+ expect(firstSummaryInstructions()).toContain(
+ "Preserve all opaque identifiers exactly as written",
+ );
+ expect(firstSummaryInstructions()).toContain("Additional focus:");
+ expect(firstSummaryInstructions()).toContain("Focus on release-impacting bugs.");
});
it("applies identifier-preservation guidance on staged split + merge summarization", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
+ await runSummary(4, {
maxChunkTokens: 1000,
- contextWindow: 200_000,
parts: 2,
minMessagesForSplit: 4,
});
@@ -91,14 +97,8 @@ describe("compaction identifier-preservation instructions", () => {
});
it("avoids duplicate additional-focus headers in split+merge path", async () => {
- await summarizeInStages({
- messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
- model: testModel,
- apiKey: "test-key",
- signal: new AbortController().signal,
- reserveTokens: 4000,
+ await runSummary(4, {
maxChunkTokens: 1000,
- contextWindow: 200_000,
parts: 2,
minMessagesForSplit: 4,
customInstructions: "Prioritize customer-visible regressions.",
diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts
index 8641b8b6c4d..b7a72585337 100644
--- a/src/agents/model-catalog.test.ts
+++ b/src/agents/model-catalog.test.ts
@@ -8,6 +8,25 @@ import {
type PiSdkModule,
} from "./model-catalog.test-harness.js";
+function mockPiDiscoveryModels(models: unknown[]) {
+ __setModelCatalogImportForTest(
+ async () =>
+ ({
+ discoverAuthStorage: () => ({}),
+ AuthStorage: class {},
+ ModelRegistry: class {
+ getAll() {
+ return models;
+ }
+ },
+ }) as unknown as PiSdkModule,
+ );
+}
+
+function mockSingleOpenAiCatalogModel() {
+ mockPiDiscoveryModels([{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]);
+}
+
describe("loadModelCatalog", () => {
installModelCatalogTestHooks();
@@ -67,32 +86,21 @@ describe("loadModelCatalog", () => {
});
it("adds openai-codex/gpt-5.3-codex-spark when base gpt-5.3-codex exists", async () => {
- __setModelCatalogImportForTest(
- async () =>
- ({
- discoverAuthStorage: () => ({}),
- AuthStorage: class {},
- ModelRegistry: class {
- getAll() {
- return [
- {
- id: "gpt-5.3-codex",
- provider: "openai-codex",
- name: "GPT-5.3 Codex",
- reasoning: true,
- contextWindow: 200000,
- input: ["text"],
- },
- {
- id: "gpt-5.2-codex",
- provider: "openai-codex",
- name: "GPT-5.2 Codex",
- },
- ];
- }
- },
- }) as unknown as PiSdkModule,
- );
+ mockPiDiscoveryModels([
+ {
+ id: "gpt-5.3-codex",
+ provider: "openai-codex",
+ name: "GPT-5.3 Codex",
+ reasoning: true,
+ contextWindow: 200000,
+ input: ["text"],
+ },
+ {
+ id: "gpt-5.2-codex",
+ provider: "openai-codex",
+ name: "GPT-5.2 Codex",
+ },
+ ]);
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
expect(result).toContainEqual(
@@ -107,18 +115,7 @@ describe("loadModelCatalog", () => {
});
it("merges configured models for opted-in non-pi-native providers", async () => {
- __setModelCatalogImportForTest(
- async () =>
- ({
- discoverAuthStorage: () => ({}),
- AuthStorage: class {},
- ModelRegistry: class {
- getAll() {
- return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }];
- }
- },
- }) as unknown as PiSdkModule,
- );
+ mockSingleOpenAiCatalogModel();
const result = await loadModelCatalog({
config: {
@@ -154,18 +151,7 @@ describe("loadModelCatalog", () => {
});
it("does not merge configured models for providers that are not opted in", async () => {
- __setModelCatalogImportForTest(
- async () =>
- ({
- discoverAuthStorage: () => ({}),
- AuthStorage: class {},
- ModelRegistry: class {
- getAll() {
- return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }];
- }
- },
- }) as unknown as PiSdkModule,
- );
+ mockSingleOpenAiCatalogModel();
const result = await loadModelCatalog({
config: {
@@ -197,24 +183,13 @@ describe("loadModelCatalog", () => {
});
it("does not duplicate opted-in configured models already present in ModelRegistry", async () => {
- __setModelCatalogImportForTest(
- async () =>
- ({
- discoverAuthStorage: () => ({}),
- AuthStorage: class {},
- ModelRegistry: class {
- getAll() {
- return [
- {
- id: "anthropic/claude-opus-4.6",
- provider: "kilocode",
- name: "Claude Opus 4.6",
- },
- ];
- }
- },
- }) as unknown as PiSdkModule,
- );
+ mockPiDiscoveryModels([
+ {
+ id: "anthropic/claude-opus-4.6",
+ provider: "kilocode",
+ name: "Claude Opus 4.6",
+ },
+ ]);
const result = await loadModelCatalog({
config: {
diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts
index 9f10e451b94..c28954bd9fb 100644
--- a/src/agents/model-selection.test.ts
+++ b/src/agents/model-selection.test.ts
@@ -15,6 +15,40 @@ import {
resolveModelRefFromString,
} from "./model-selection.js";
+const EXPLICIT_ALLOWLIST_CONFIG = {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.2" },
+ models: {
+ "anthropic/claude-sonnet-4-6": { alias: "sonnet" },
+ },
+ },
+ },
+} as OpenClawConfig;
+
+const BUNDLED_ALLOWLIST_CATALOG = [
+ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
+ { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
+];
+
+const ANTHROPIC_OPUS_CATALOG = [
+ {
+ provider: "anthropic",
+ id: "claude-opus-4-6",
+ name: "Claude Opus 4.6",
+ reasoning: true,
+ },
+];
+
+function resolveAnthropicOpusThinking(cfg: OpenClawConfig) {
+ return resolveThinkingDefault({
+ cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-6",
+ catalog: ANTHROPIC_OPUS_CATALOG,
+ });
+}
+
describe("model-selection", () => {
describe("normalizeProviderId", () => {
it("should normalize provider names", () => {
@@ -245,25 +279,9 @@ describe("model-selection", () => {
describe("buildAllowedModelSet", () => {
it("keeps explicitly allowlisted models even when missing from bundled catalog", () => {
- const cfg: OpenClawConfig = {
- agents: {
- defaults: {
- model: { primary: "openai/gpt-5.2" },
- models: {
- "anthropic/claude-sonnet-4-6": { alias: "sonnet" },
- },
- },
- },
- } as OpenClawConfig;
-
- const catalog = [
- { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
- { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
- ];
-
const result = buildAllowedModelSet({
- cfg,
- catalog,
+ cfg: EXPLICIT_ALLOWLIST_CONFIG,
+ catalog: BUNDLED_ALLOWLIST_CATALOG,
defaultProvider: "anthropic",
});
@@ -277,25 +295,9 @@ describe("model-selection", () => {
describe("resolveAllowedModelRef", () => {
it("accepts explicit allowlist refs absent from bundled catalog", () => {
- const cfg: OpenClawConfig = {
- agents: {
- defaults: {
- model: { primary: "openai/gpt-5.2" },
- models: {
- "anthropic/claude-sonnet-4-6": { alias: "sonnet" },
- },
- },
- },
- } as OpenClawConfig;
-
- const catalog = [
- { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
- { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
- ];
-
const result = resolveAllowedModelRef({
- cfg,
- catalog,
+ cfg: EXPLICIT_ALLOWLIST_CONFIG,
+ catalog: BUNDLED_ALLOWLIST_CATALOG,
raw: "anthropic/claude-sonnet-4-6",
defaultProvider: "openai",
defaultModel: "gpt-5.2",
@@ -487,21 +489,7 @@ describe("model-selection", () => {
},
} as OpenClawConfig;
- expect(
- resolveThinkingDefault({
- cfg,
- provider: "anthropic",
- model: "claude-opus-4-6",
- catalog: [
- {
- provider: "anthropic",
- id: "claude-opus-4-6",
- name: "Claude Opus 4.6",
- reasoning: true,
- },
- ],
- }),
- ).toBe("high");
+ expect(resolveAnthropicOpusThinking(cfg)).toBe("high");
});
it("accepts per-model params.thinking=adaptive", () => {
@@ -517,41 +505,13 @@ describe("model-selection", () => {
},
} as OpenClawConfig;
- expect(
- resolveThinkingDefault({
- cfg,
- provider: "anthropic",
- model: "claude-opus-4-6",
- catalog: [
- {
- provider: "anthropic",
- id: "claude-opus-4-6",
- name: "Claude Opus 4.6",
- reasoning: true,
- },
- ],
- }),
- ).toBe("adaptive");
+ expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
});
it("defaults Anthropic Claude 4.6 models to adaptive", () => {
const cfg = {} as OpenClawConfig;
- expect(
- resolveThinkingDefault({
- cfg,
- provider: "anthropic",
- model: "claude-opus-4-6",
- catalog: [
- {
- provider: "anthropic",
- id: "claude-opus-4-6",
- name: "Claude Opus 4.6",
- reasoning: true,
- },
- ],
- }),
- ).toBe("adaptive");
+ expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
expect(
resolveThinkingDefault({
diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
index e7ddd2f5872..e8702461883 100644
--- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
+++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
@@ -14,6 +14,98 @@ import { readGeneratedModelsJson } from "./models-config.test-utils.js";
installModelsConfigTestHooks();
+const MODELS_JSON_NAME = "models.json";
+
+async function withEnvVar(name: string, value: string, run: () => Promise) {
+ const previous = process.env[name];
+ process.env[name] = value;
+ try {
+ await run();
+ } finally {
+ if (previous === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = previous;
+ }
+ }
+}
+
+async function writeAgentModelsJson(content: unknown): Promise {
+ const agentDir = resolveOpenClawAgentDir();
+ await fs.mkdir(agentDir, { recursive: true });
+ await fs.writeFile(
+ path.join(agentDir, MODELS_JSON_NAME),
+ JSON.stringify(content, null, 2),
+ "utf8",
+ );
+}
+
+function createMergeConfigProvider() {
+ return {
+ baseUrl: "https://config.example/v1",
+ apiKey: "CONFIG_KEY",
+ api: "openai-responses",
+ models: [
+ {
+ id: "config-model",
+ name: "Config model",
+ input: ["text"],
+ reasoning: false,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 8192,
+ maxTokens: 2048,
+ },
+ ],
+ } as const;
+}
+
+async function runCustomProviderMergeTest(seedProvider: {
+ baseUrl: string;
+ apiKey: string;
+ api: string;
+ models: Array<{ id: string; name: string; input: string[] }>;
+}) {
+ await writeAgentModelsJson({ providers: { custom: seedProvider } });
+ await ensureOpenClawModelsJson({
+ models: {
+ mode: "merge",
+ providers: {
+ custom: createMergeConfigProvider(),
+ },
+ },
+ });
+ return readGeneratedModelsJson<{
+ providers: Record;
+ }>();
+}
+
+function createMoonshotConfig(overrides: {
+ contextWindow: number;
+ maxTokens: number;
+}): OpenClawConfig {
+ return {
+ models: {
+ providers: {
+ moonshot: {
+ baseUrl: "https://api.moonshot.ai/v1",
+ api: "openai-completions",
+ models: [
+ {
+ id: "kimi-k2.5",
+ name: "Kimi K2.5",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: overrides.contextWindow,
+ maxTokens: overrides.maxTokens,
+ },
+ ],
+ },
+ },
+ },
+ };
+}
+
describe("models-config", () => {
it("keeps anthropic api defaults when model entries omit api", async () => {
await withTempHome(async () => {
@@ -46,9 +138,7 @@ describe("models-config", () => {
it("fills missing provider.apiKey from env var name when models exist", async () => {
await withTempHome(async () => {
- const prevKey = process.env.MINIMAX_API_KEY;
- process.env.MINIMAX_API_KEY = "sk-minimax-test";
- try {
+ await withEnvVar("MINIMAX_API_KEY", "sk-minimax-test", async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
@@ -79,55 +169,38 @@ describe("models-config", () => {
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-VL-01");
- } finally {
- if (prevKey === undefined) {
- delete process.env.MINIMAX_API_KEY;
- } else {
- process.env.MINIMAX_API_KEY = prevKey;
- }
- }
+ });
});
});
it("merges providers by default", async () => {
await withTempHome(async () => {
- const agentDir = resolveOpenClawAgentDir();
- await fs.mkdir(agentDir, { recursive: true });
- await fs.writeFile(
- path.join(agentDir, "models.json"),
- JSON.stringify(
- {
- providers: {
- existing: {
- baseUrl: "http://localhost:1234/v1",
- apiKey: "EXISTING_KEY",
+ await writeAgentModelsJson({
+ providers: {
+ existing: {
+ baseUrl: "http://localhost:1234/v1",
+ apiKey: "EXISTING_KEY",
+ api: "openai-completions",
+ models: [
+ {
+ id: "existing-model",
+ name: "Existing",
api: "openai-completions",
- models: [
- {
- id: "existing-model",
- name: "Existing",
- api: "openai-completions",
- reasoning: false,
- input: ["text"],
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 8192,
- maxTokens: 2048,
- },
- ],
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 8192,
+ maxTokens: 2048,
},
- },
+ ],
},
- null,
- 2,
- ),
- "utf8",
- );
+ },
+ });
await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG);
- const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
- const parsed = JSON.parse(raw) as {
+ const parsed = await readGeneratedModelsJson<{
providers: Record;
- };
+ }>();
expect(parsed.providers.existing?.baseUrl).toBe("http://localhost:1234/v1");
expect(parsed.providers["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
@@ -136,54 +209,12 @@ describe("models-config", () => {
it("preserves non-empty agent apiKey/baseUrl for matching providers in merge mode", async () => {
await withTempHome(async () => {
- const agentDir = resolveOpenClawAgentDir();
- await fs.mkdir(agentDir, { recursive: true });
- await fs.writeFile(
- path.join(agentDir, "models.json"),
- JSON.stringify(
- {
- providers: {
- custom: {
- baseUrl: "https://agent.example/v1",
- apiKey: "AGENT_KEY",
- api: "openai-responses",
- models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
- },
- },
- },
- null,
- 2,
- ),
- "utf8",
- );
-
- await ensureOpenClawModelsJson({
- models: {
- mode: "merge",
- providers: {
- custom: {
- baseUrl: "https://config.example/v1",
- apiKey: "CONFIG_KEY",
- api: "openai-responses",
- models: [
- {
- id: "config-model",
- name: "Config model",
- input: ["text"],
- reasoning: false,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 8192,
- maxTokens: 2048,
- },
- ],
- },
- },
- },
+ const parsed = await runCustomProviderMergeTest({
+ baseUrl: "https://agent.example/v1",
+ apiKey: "AGENT_KEY",
+ api: "openai-responses",
+ models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
});
-
- const parsed = await readGeneratedModelsJson<{
- providers: Record;
- }>();
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1");
});
@@ -191,54 +222,12 @@ describe("models-config", () => {
it("uses config apiKey/baseUrl when existing agent values are empty", async () => {
await withTempHome(async () => {
- const agentDir = resolveOpenClawAgentDir();
- await fs.mkdir(agentDir, { recursive: true });
- await fs.writeFile(
- path.join(agentDir, "models.json"),
- JSON.stringify(
- {
- providers: {
- custom: {
- baseUrl: "",
- apiKey: "",
- api: "openai-responses",
- models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
- },
- },
- },
- null,
- 2,
- ),
- "utf8",
- );
-
- await ensureOpenClawModelsJson({
- models: {
- mode: "merge",
- providers: {
- custom: {
- baseUrl: "https://config.example/v1",
- apiKey: "CONFIG_KEY",
- api: "openai-responses",
- models: [
- {
- id: "config-model",
- name: "Config model",
- input: ["text"],
- reasoning: false,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 8192,
- maxTokens: 2048,
- },
- ],
- },
- },
- },
+ const parsed = await runCustomProviderMergeTest({
+ baseUrl: "",
+ apiKey: "",
+ api: "openai-responses",
+ models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
});
-
- const parsed = await readGeneratedModelsJson<{
- providers: Record;
- }>();
expect(parsed.providers.custom?.apiKey).toBe("CONFIG_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
});
@@ -246,36 +235,12 @@ describe("models-config", () => {
it("refreshes stale explicit moonshot model capabilities from implicit catalog", async () => {
await withTempHome(async () => {
- const prevKey = process.env.MOONSHOT_API_KEY;
- process.env.MOONSHOT_API_KEY = "sk-moonshot-test";
- try {
- const cfg: OpenClawConfig = {
- models: {
- providers: {
- moonshot: {
- baseUrl: "https://api.moonshot.ai/v1",
- api: "openai-completions",
- models: [
- {
- id: "kimi-k2.5",
- name: "Kimi K2.5",
- reasoning: false,
- input: ["text"],
- cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 1024,
- maxTokens: 256,
- },
- ],
- },
- },
- },
- };
+ await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
+ const cfg = createMoonshotConfig({ contextWindow: 1024, maxTokens: 256 });
await ensureOpenClawModelsJson(cfg);
- const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
- const raw = await fs.readFile(modelPath, "utf8");
- const parsed = JSON.parse(raw) as {
+ const parsed = await readGeneratedModelsJson<{
providers: Record<
string,
{
@@ -289,7 +254,7 @@ describe("models-config", () => {
}>;
}
>;
- };
+ }>();
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
expect(kimi?.input).toEqual(["text", "image"]);
expect(kimi?.reasoning).toBe(false);
@@ -298,42 +263,14 @@ describe("models-config", () => {
// Preserve explicit user pricing overrides when refreshing capabilities.
expect(kimi?.cost?.input).toBe(123);
expect(kimi?.cost?.output).toBe(456);
- } finally {
- if (prevKey === undefined) {
- delete process.env.MOONSHOT_API_KEY;
- } else {
- process.env.MOONSHOT_API_KEY = prevKey;
- }
- }
+ });
});
});
it("preserves explicit larger token limits when they exceed implicit catalog defaults", async () => {
await withTempHome(async () => {
- const prevKey = process.env.MOONSHOT_API_KEY;
- process.env.MOONSHOT_API_KEY = "sk-moonshot-test";
- try {
- const cfg: OpenClawConfig = {
- models: {
- providers: {
- moonshot: {
- baseUrl: "https://api.moonshot.ai/v1",
- api: "openai-completions",
- models: [
- {
- id: "kimi-k2.5",
- name: "Kimi K2.5",
- reasoning: false,
- input: ["text"],
- cost: { input: 123, output: 456, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 350000,
- maxTokens: 16384,
- },
- ],
- },
- },
- },
- };
+ await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
+ const cfg = createMoonshotConfig({ contextWindow: 350000, maxTokens: 16384 });
await ensureOpenClawModelsJson(cfg);
const parsed = await readGeneratedModelsJson<{
@@ -351,13 +288,7 @@ describe("models-config", () => {
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
expect(kimi?.contextWindow).toBe(350000);
expect(kimi?.maxTokens).toBe(16384);
- } finally {
- if (prevKey === undefined) {
- delete process.env.MOONSHOT_API_KEY;
- } else {
- process.env.MOONSHOT_API_KEY = prevKey;
- }
- }
+ });
});
});
});
diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts
index 6a3601aa894..b1dd8ca49f0 100644
--- a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts
+++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts
@@ -1,13 +1,11 @@
-import fs from "node:fs/promises";
-import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
-import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
installModelsConfigTestHooks,
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
+import { readGeneratedModelsJson } from "./models-config.test-utils.js";
installModelsConfigTestHooks();
@@ -22,23 +20,49 @@ type ModelsJson = {
providers: Record;
};
+const MINIMAX_ENV_KEY = "MINIMAX_API_KEY";
+const MINIMAX_MODEL_ID = "MiniMax-M2.5";
+const MINIMAX_TEST_KEY = "sk-minimax-test";
+
+const baseMinimaxProvider = {
+ baseUrl: "https://api.minimax.io/anthropic",
+ api: "anthropic-messages",
+} as const;
+
+async function withMinimaxApiKey(run: () => Promise) {
+ const prev = process.env[MINIMAX_ENV_KEY];
+ process.env[MINIMAX_ENV_KEY] = MINIMAX_TEST_KEY;
+ try {
+ await run();
+ } finally {
+ if (prev === undefined) {
+ delete process.env[MINIMAX_ENV_KEY];
+ } else {
+ process.env[MINIMAX_ENV_KEY] = prev;
+ }
+ }
+}
+
+async function generateAndReadMinimaxModel(cfg: OpenClawConfig): Promise {
+ await ensureOpenClawModelsJson(cfg);
+ const parsed = await readGeneratedModelsJson();
+ return parsed.providers.minimax?.models?.find((model) => model.id === MINIMAX_MODEL_ID);
+}
+
describe("models-config: explicit reasoning override", () => {
it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => {
// MiniMax-M2.5 has reasoning:true in the built-in catalog.
// User explicitly sets reasoning:false to avoid message-ordering conflicts.
await withTempHome(async () => {
- const prevKey = process.env.MINIMAX_API_KEY;
- process.env.MINIMAX_API_KEY = "sk-minimax-test";
- try {
+ await withMinimaxApiKey(async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
minimax: {
- baseUrl: "https://api.minimax.io/anthropic",
- api: "anthropic-messages",
+ ...baseMinimaxProvider,
models: [
{
- id: "MiniMax-M2.5",
+ id: MINIMAX_MODEL_ID,
name: "MiniMax M2.5",
reasoning: false, // explicit override: user wants to disable reasoning
input: ["text"],
@@ -52,21 +76,11 @@ describe("models-config: explicit reasoning override", () => {
},
};
- await ensureOpenClawModelsJson(cfg);
-
- const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8");
- const parsed = JSON.parse(raw) as ModelsJson;
- const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5");
+ const m25 = await generateAndReadMinimaxModel(cfg);
expect(m25).toBeDefined();
// Must honour the explicit false — built-in true must NOT win.
expect(m25?.reasoning).toBe(false);
- } finally {
- if (prevKey === undefined) {
- delete process.env.MINIMAX_API_KEY;
- } else {
- process.env.MINIMAX_API_KEY = prevKey;
- }
- }
+ });
});
});
@@ -74,12 +88,10 @@ describe("models-config: explicit reasoning override", () => {
// When the user does not set reasoning at all, the built-in catalog value
// (true for MiniMax-M2.5) should be used so the model works out of the box.
await withTempHome(async () => {
- const prevKey = process.env.MINIMAX_API_KEY;
- process.env.MINIMAX_API_KEY = "sk-minimax-test";
- try {
+ await withMinimaxApiKey(async () => {
// Omit 'reasoning' to simulate a user config that doesn't set it.
const modelWithoutReasoning = {
- id: "MiniMax-M2.5",
+ id: MINIMAX_MODEL_ID,
name: "MiniMax M2.5",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -90,8 +102,7 @@ describe("models-config: explicit reasoning override", () => {
models: {
providers: {
minimax: {
- baseUrl: "https://api.minimax.io/anthropic",
- api: "anthropic-messages",
+ ...baseMinimaxProvider,
// @ts-expect-error Intentional: emulate user config omitting reasoning.
models: [modelWithoutReasoning],
},
@@ -99,21 +110,11 @@ describe("models-config: explicit reasoning override", () => {
},
};
- await ensureOpenClawModelsJson(cfg);
-
- const raw = await fs.readFile(path.join(resolveOpenClawAgentDir(), "models.json"), "utf8");
- const parsed = JSON.parse(raw) as ModelsJson;
- const m25 = parsed.providers.minimax?.models?.find((m) => m.id === "MiniMax-M2.5");
+ const m25 = await generateAndReadMinimaxModel(cfg);
expect(m25).toBeDefined();
// Built-in catalog has reasoning:true — should be applied as default.
expect(m25?.reasoning).toBe(true);
- } finally {
- if (prevKey === undefined) {
- delete process.env.MINIMAX_API_KEY;
- } else {
- process.env.MINIMAX_API_KEY = prevKey;
- }
- }
+ });
});
});
});
diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts
index 7e3132b3152..c44b5aa2c88 100644
--- a/src/agents/openclaw-tools.camera.test.ts
+++ b/src/agents/openclaw-tools.camera.test.ts
@@ -15,6 +15,14 @@ import { createOpenClawTools } from "./openclaw-tools.js";
const NODE_ID = "mac-1";
const BASE_RUN_INPUT = { action: "run", node: NODE_ID, command: ["echo", "hi"] } as const;
+const JPG_PAYLOAD = {
+ format: "jpg",
+ base64: "aGVsbG8=",
+ width: 1,
+ height: 1,
+} as const;
+
+type GatewayCall = { method: string; params?: unknown };
function unexpectedGatewayMethod(method: unknown): never {
throw new Error(`unexpected method: ${String(method)}`);
@@ -32,24 +40,99 @@ async function executeNodes(input: Record) {
return getNodesTool().execute("call1", input as never);
}
+type NodesToolResult = Awaited>;
+type GatewayMockResult = Record | null | undefined;
+
function mockNodeList(commands?: string[]) {
return {
nodes: [{ nodeId: NODE_ID, ...(commands ? { commands } : {}) }],
};
}
+function expectSingleImage(result: NodesToolResult, params?: { mimeType?: string }) {
+ const images = (result.content ?? []).filter((block) => block.type === "image");
+ expect(images).toHaveLength(1);
+ if (params?.mimeType) {
+ expect(images[0]?.mimeType).toBe(params.mimeType);
+ }
+}
+
+function expectFirstTextContains(result: NodesToolResult, expectedText: string) {
+ expect(result.content?.[0]).toMatchObject({
+ type: "text",
+ text: expect.stringContaining(expectedText),
+ });
+}
+
+function setupNodeInvokeMock(params: {
+ commands?: string[];
+ onInvoke?: (invokeParams: unknown) => GatewayMockResult | Promise;
+ invokePayload?: unknown;
+}) {
+ callGateway.mockImplementation(async ({ method, params: invokeParams }: GatewayCall) => {
+ if (method === "node.list") {
+ return mockNodeList(params.commands);
+ }
+ if (method === "node.invoke") {
+ if (params.onInvoke) {
+ return await params.onInvoke(invokeParams);
+ }
+ if (params.invokePayload !== undefined) {
+ return { payload: params.invokePayload };
+ }
+ return { payload: {} };
+ }
+ return unexpectedGatewayMethod(method);
+ });
+}
+
+function createSystemRunPreparePayload(cwd: string | null) {
+ return {
+ payload: {
+ cmdText: "echo hi",
+ plan: {
+ argv: ["echo", "hi"],
+ cwd,
+ rawCommand: "echo hi",
+ agentId: null,
+ sessionKey: null,
+ },
+ },
+ };
+}
+
+function setupSystemRunGateway(params: {
+ onRunInvoke: (invokeParams: unknown) => GatewayMockResult | Promise;
+ onApprovalRequest?: (approvalParams: unknown) => GatewayMockResult | Promise;
+ prepareCwd?: string | null;
+}) {
+ callGateway.mockImplementation(async ({ method, params: gatewayParams }: GatewayCall) => {
+ if (method === "node.list") {
+ return mockNodeList(["system.run"]);
+ }
+ if (method === "node.invoke") {
+ const command = (gatewayParams as { command?: string } | undefined)?.command;
+ if (command === "system.run.prepare") {
+ return createSystemRunPreparePayload(params.prepareCwd ?? null);
+ }
+ return await params.onRunInvoke(gatewayParams);
+ }
+ if (method === "exec.approval.request" && params.onApprovalRequest) {
+ return await params.onApprovalRequest(gatewayParams);
+ }
+ return unexpectedGatewayMethod(method);
+ });
+}
+
beforeEach(() => {
callGateway.mockClear();
});
describe("nodes camera_snap", () => {
it("uses front/high-quality defaults when params are omitted", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList();
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
command: "camera.snap",
params: {
facing: "front",
@@ -57,16 +140,8 @@ describe("nodes camera_snap", () => {
quality: 0.95,
},
});
- return {
- payload: {
- format: "jpg",
- base64: "aGVsbG8=",
- width: 1,
- height: 1,
- },
- };
- }
- return unexpectedGatewayMethod(method);
+ return { payload: JPG_PAYLOAD };
+ },
});
const result = await executeNodes({
@@ -74,26 +149,12 @@ describe("nodes camera_snap", () => {
node: NODE_ID,
});
- const images = (result.content ?? []).filter((block) => block.type === "image");
- expect(images).toHaveLength(1);
+ expectSingleImage(result);
});
it("maps jpg payloads to image/jpeg", async () => {
- callGateway.mockImplementation(async ({ method }) => {
- if (method === "node.list") {
- return mockNodeList();
- }
- if (method === "node.invoke") {
- return {
- payload: {
- format: "jpg",
- base64: "aGVsbG8=",
- width: 1,
- height: 1,
- },
- };
- }
- return unexpectedGatewayMethod(method);
+ setupNodeInvokeMock({
+ invokePayload: JPG_PAYLOAD,
});
const result = await executeNodes({
@@ -102,31 +163,18 @@ describe("nodes camera_snap", () => {
facing: "front",
});
- const images = (result.content ?? []).filter((block) => block.type === "image");
- expect(images).toHaveLength(1);
- expect(images[0]?.mimeType).toBe("image/jpeg");
+ expectSingleImage(result, { mimeType: "image/jpeg" });
});
it("passes deviceId when provided", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList();
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
command: "camera.snap",
params: { deviceId: "cam-123" },
});
- return {
- payload: {
- format: "jpg",
- base64: "aGVsbG8=",
- width: 1,
- height: 1,
- },
- };
- }
- return unexpectedGatewayMethod(method);
+ return { payload: JPG_PAYLOAD };
+ },
});
await executeNodes({
@@ -151,12 +199,10 @@ describe("nodes camera_snap", () => {
describe("nodes notifications_list", () => {
it("invokes notifications.list and returns payload", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["notifications.list"]);
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ commands: ["notifications.list"],
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "notifications.list",
params: {},
@@ -169,8 +215,7 @@ describe("nodes notifications_list", () => {
notifications: [{ key: "n1", packageName: "com.example.app" }],
},
};
- }
- return unexpectedGatewayMethod(method);
+ },
});
const result = await executeNodes({
@@ -178,21 +223,16 @@ describe("nodes notifications_list", () => {
node: NODE_ID,
});
- expect(result.content?.[0]).toMatchObject({
- type: "text",
- text: expect.stringContaining('"notifications"'),
- });
+ expectFirstTextContains(result, '"notifications"');
});
});
describe("nodes notifications_action", () => {
it("invokes notifications.actions dismiss", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["notifications.actions"]);
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ commands: ["notifications.actions"],
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "notifications.actions",
params: {
@@ -201,8 +241,7 @@ describe("nodes notifications_action", () => {
},
});
return { payload: { ok: true, key: "n1", action: "dismiss" } };
- }
- return unexpectedGatewayMethod(method);
+ },
});
const result = await executeNodes({
@@ -212,21 +251,16 @@ describe("nodes notifications_action", () => {
notificationAction: "dismiss",
});
- expect(result.content?.[0]).toMatchObject({
- type: "text",
- text: expect.stringContaining('"dismiss"'),
- });
+ expectFirstTextContains(result, '"dismiss"');
});
});
describe("nodes device_status and device_info", () => {
it("invokes device.status and returns payload", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["device.status", "device.info"]);
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ commands: ["device.status", "device.info"],
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "device.status",
params: {},
@@ -236,8 +270,7 @@ describe("nodes device_status and device_info", () => {
battery: { state: "charging", lowPowerModeEnabled: false },
},
};
- }
- return unexpectedGatewayMethod(method);
+ },
});
const result = await executeNodes({
@@ -245,19 +278,14 @@ describe("nodes device_status and device_info", () => {
node: NODE_ID,
});
- expect(result.content?.[0]).toMatchObject({
- type: "text",
- text: expect.stringContaining('"battery"'),
- });
+ expectFirstTextContains(result, '"battery"');
});
it("invokes device.info and returns payload", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["device.status", "device.info"]);
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ commands: ["device.status", "device.info"],
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "device.info",
params: {},
@@ -268,8 +296,7 @@ describe("nodes device_status and device_info", () => {
appVersion: "1.0.0",
},
};
- }
- return unexpectedGatewayMethod(method);
+ },
});
const result = await executeNodes({
@@ -277,19 +304,14 @@ describe("nodes device_status and device_info", () => {
node: NODE_ID,
});
- expect(result.content?.[0]).toMatchObject({
- type: "text",
- text: expect.stringContaining('"systemName"'),
- });
+ expectFirstTextContains(result, '"systemName"');
});
it("invokes device.permissions and returns payload", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["device.permissions"]);
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ commands: ["device.permissions"],
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "device.permissions",
params: {},
@@ -301,8 +323,7 @@ describe("nodes device_status and device_info", () => {
},
},
};
- }
- return unexpectedGatewayMethod(method);
+ },
});
const result = await executeNodes({
@@ -310,19 +331,14 @@ describe("nodes device_status and device_info", () => {
node: NODE_ID,
});
- expect(result.content?.[0]).toMatchObject({
- type: "text",
- text: expect.stringContaining('"permissions"'),
- });
+ expectFirstTextContains(result, '"permissions"');
});
it("invokes device.health and returns payload", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["device.health"]);
- }
- if (method === "node.invoke") {
- expect(params).toMatchObject({
+ setupNodeInvokeMock({
+ commands: ["device.health"],
+ onInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "device.health",
params: {},
@@ -333,8 +349,7 @@ describe("nodes device_status and device_info", () => {
battery: { chargingType: "usb" },
},
};
- }
- return unexpectedGatewayMethod(method);
+ },
});
const result = await executeNodes({
@@ -342,36 +357,16 @@ describe("nodes device_status and device_info", () => {
node: NODE_ID,
});
- expect(result.content?.[0]).toMatchObject({
- type: "text",
- text: expect.stringContaining('"memory"'),
- });
+ expectFirstTextContains(result, '"memory"');
});
});
describe("nodes run", () => {
it("passes invoke and command timeouts", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["system.run"]);
- }
- if (method === "node.invoke") {
- const command = (params as { command?: string } | undefined)?.command;
- if (command === "system.run.prepare") {
- return {
- payload: {
- cmdText: "echo hi",
- plan: {
- argv: ["echo", "hi"],
- cwd: "/tmp",
- rawCommand: "echo hi",
- agentId: null,
- sessionKey: null,
- },
- },
- };
- }
- expect(params).toMatchObject({
+ setupSystemRunGateway({
+ prepareCwd: "/tmp",
+ onRunInvoke: (invokeParams) => {
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "system.run",
timeoutMs: 45_000,
@@ -385,8 +380,7 @@ describe("nodes run", () => {
return {
payload: { stdout: "", stderr: "", exitCode: 0, success: true },
};
- }
- return unexpectedGatewayMethod(method);
+ },
});
await executeNodes({
@@ -401,31 +395,13 @@ describe("nodes run", () => {
it("requests approval and retries with allow-once decision", async () => {
let invokeCalls = 0;
let approvalId: string | null = null;
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["system.run"]);
- }
- if (method === "node.invoke") {
- const command = (params as { command?: string } | undefined)?.command;
- if (command === "system.run.prepare") {
- return {
- payload: {
- cmdText: "echo hi",
- plan: {
- argv: ["echo", "hi"],
- cwd: null,
- rawCommand: "echo hi",
- agentId: null,
- sessionKey: null,
- },
- },
- };
- }
+ setupSystemRunGateway({
+ onRunInvoke: (invokeParams) => {
invokeCalls += 1;
if (invokeCalls === 1) {
throw new Error("SYSTEM_RUN_DENIED: approval required");
}
- expect(params).toMatchObject({
+ expect(invokeParams).toMatchObject({
nodeId: NODE_ID,
command: "system.run",
params: {
@@ -436,9 +412,9 @@ describe("nodes run", () => {
},
});
return { payload: { stdout: "", stderr: "", exitCode: 0, success: true } };
- }
- if (method === "exec.approval.request") {
- expect(params).toMatchObject({
+ },
+ onApprovalRequest: (approvalParams) => {
+ expect(approvalParams).toMatchObject({
id: expect.any(String),
command: "echo hi",
commandArgv: ["echo", "hi"],
@@ -450,12 +426,11 @@ describe("nodes run", () => {
timeoutMs: 120_000,
});
approvalId =
- typeof (params as { id?: unknown } | undefined)?.id === "string"
- ? ((params as { id: string }).id ?? null)
+ typeof (approvalParams as { id?: unknown } | undefined)?.id === "string"
+ ? ((approvalParams as { id: string }).id ?? null)
: null;
return { decision: "allow-once" };
- }
- return unexpectedGatewayMethod(method);
+ },
});
await executeNodes(BASE_RUN_INPUT);
@@ -463,93 +438,36 @@ describe("nodes run", () => {
});
it("fails with user denied when approval decision is deny", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["system.run"]);
- }
- if (method === "node.invoke") {
- const command = (params as { command?: string } | undefined)?.command;
- if (command === "system.run.prepare") {
- return {
- payload: {
- cmdText: "echo hi",
- plan: {
- argv: ["echo", "hi"],
- cwd: null,
- rawCommand: "echo hi",
- agentId: null,
- sessionKey: null,
- },
- },
- };
- }
+ setupSystemRunGateway({
+ onRunInvoke: () => {
throw new Error("SYSTEM_RUN_DENIED: approval required");
- }
- if (method === "exec.approval.request") {
+ },
+ onApprovalRequest: () => {
return { decision: "deny" };
- }
- return unexpectedGatewayMethod(method);
+ },
});
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: user denied");
});
it("fails closed for timeout and invalid approval decisions", async () => {
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["system.run"]);
- }
- if (method === "node.invoke") {
- const command = (params as { command?: string } | undefined)?.command;
- if (command === "system.run.prepare") {
- return {
- payload: {
- cmdText: "echo hi",
- plan: {
- argv: ["echo", "hi"],
- cwd: null,
- rawCommand: "echo hi",
- agentId: null,
- sessionKey: null,
- },
- },
- };
- }
+ setupSystemRunGateway({
+ onRunInvoke: () => {
throw new Error("SYSTEM_RUN_DENIED: approval required");
- }
- if (method === "exec.approval.request") {
+ },
+ onApprovalRequest: () => {
return {};
- }
- return unexpectedGatewayMethod(method);
+ },
});
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: approval timed out");
- callGateway.mockImplementation(async ({ method, params }) => {
- if (method === "node.list") {
- return mockNodeList(["system.run"]);
- }
- if (method === "node.invoke") {
- const command = (params as { command?: string } | undefined)?.command;
- if (command === "system.run.prepare") {
- return {
- payload: {
- cmdText: "echo hi",
- plan: {
- argv: ["echo", "hi"],
- cwd: null,
- rawCommand: "echo hi",
- agentId: null,
- sessionKey: null,
- },
- },
- };
- }
+ setupSystemRunGateway({
+ onRunInvoke: () => {
throw new Error("SYSTEM_RUN_DENIED: approval required");
- }
- if (method === "exec.approval.request") {
+ },
+ onApprovalRequest: () => {
return { decision: "allow-never" };
- }
- return unexpectedGatewayMethod(method);
+ },
});
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow(
"exec denied: invalid approval decision",
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts
index 279566a0ecd..a01e8d461b5 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn-applies-thinking-default.test.ts
@@ -1,83 +1,78 @@
-import { describe, expect, it, vi } from "vitest";
-import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
+import { beforeEach, describe, expect, it } from "vitest";
+import "./test-helpers/fast-core-tools.js";
+import * as harness from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
+import { resetSubagentRegistryForTests } from "./subagent-registry.js";
-vi.mock("../config/config.js", async () => {
- const actual = await vi.importActual("../config/config.js");
- return {
- ...actual,
- loadConfig: () => ({
- agents: {
- defaults: {
- subagents: {
- thinking: "high",
- },
- },
- },
- routing: {
- sessions: {
- mainKey: "agent:test:main",
- },
- },
- }),
- };
-});
+const MAIN_SESSION_KEY = "agent:test:main";
-vi.mock("../gateway/call.js", () => {
- return {
- callGateway: vi.fn(async ({ method }: { method: string }) => {
- if (method === "agent") {
- return { runId: "run-123" };
- }
- return {};
- }),
- };
-});
+type ThinkingLevel = "high" | "medium" | "low";
-type GatewayCall = { method: string; params?: Record };
-
-async function getGatewayCalls(): Promise {
- const { callGateway } = await import("../gateway/call.js");
- return (callGateway as unknown as ReturnType).mock.calls.map(
- (call) => call[0] as GatewayCall,
- );
+function applyThinkingDefault(thinking: ThinkingLevel) {
+ harness.setSessionsSpawnConfigOverride({
+ session: { mainKey: "main", scope: "per-sender" },
+ agents: { defaults: { subagents: { thinking } } },
+ routing: { sessions: { mainKey: MAIN_SESSION_KEY } },
+ });
}
-function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
- for (let i = calls.length - 1; i >= 0; i -= 1) {
- const call = calls[i];
- if (call && predicate(call)) {
- return call;
+function findSubagentThinking(
+ calls: Array<{ method?: string; params?: unknown }>,
+): string | undefined {
+ for (const call of calls) {
+ if (call.method !== "agent") {
+ continue;
+ }
+ const params = call.params as { lane?: string; thinking?: string } | undefined;
+ if (params?.lane === "subagent") {
+ return params.thinking;
}
}
return undefined;
}
-async function expectThinkingPropagation(params: {
+function findPatchedThinking(
+ calls: Array<{ method?: string; params?: unknown }>,
+): string | undefined {
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
+ const entry = calls[index];
+ if (!entry || entry.method !== "sessions.patch") {
+ continue;
+ }
+ const params = entry.params as { thinkingLevel?: string } | undefined;
+ if (params?.thinkingLevel) {
+ return params.thinkingLevel;
+ }
+ }
+ return undefined;
+}
+
+async function expectThinkingPropagation(input: {
callId: string;
payload: Record;
- expectedThinking: string;
+ expected: ThinkingLevel;
}) {
- const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
- const result = await tool.execute(params.callId, params.payload);
+ const gateway = harness.setupSessionsSpawnGatewayMock({});
+ const tool = await harness.getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY });
+ const result = await tool.execute(input.callId, input.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);
+ expect(findSubagentThinking(gateway.calls)).toBe(input.expected);
+ expect(findPatchedThinking(gateway.calls)).toBe(input.expected);
}
describe("sessions_spawn thinking defaults", () => {
+ beforeEach(() => {
+ harness.resetSessionsSpawnConfigOverride();
+ resetSubagentRegistryForTests();
+ harness.getCallGatewayMock().mockClear();
+ applyThinkingDefault("high");
+ });
+
it("applies agents.defaults.subagents.thinking when thinking is omitted", async () => {
await expectThinkingPropagation({
callId: "call-1",
payload: { task: "hello" },
- expectedThinking: "high",
+ expected: "high",
});
});
@@ -85,7 +80,7 @@ describe("sessions_spawn thinking defaults", () => {
await expectThinkingPropagation({
callId: "call-2",
payload: { task: "hello", thinking: "low" },
- expectedThinking: "low",
+ expected: "low",
});
});
});
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts
index 947c83333fd..bf23d3d68c3 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts
@@ -1,69 +1,50 @@
-import { describe, expect, it, vi } from "vitest";
-import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
+import { beforeEach, describe, expect, it } from "vitest";
+import "./test-helpers/fast-core-tools.js";
+import {
+ getCallGatewayMock,
+ getSessionsSpawnTool,
+ resetSessionsSpawnConfigOverride,
+ setSessionsSpawnConfigOverride,
+ setupSessionsSpawnGatewayMock,
+} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
+import { resetSubagentRegistryForTests } from "./subagent-registry.js";
-vi.mock("../config/config.js", async () => {
- const actual = await vi.importActual("../config/config.js");
- return {
- ...actual,
- loadConfig: () => ({
- agents: {
- defaults: {
- subagents: {
- maxConcurrent: 8,
- },
- },
- },
- routing: {
- sessions: {
- mainKey: "agent:test:main",
- },
- },
- }),
- };
-});
+const MAIN_SESSION_KEY = "agent:test:main";
-vi.mock("../gateway/call.js", () => {
- return {
- callGateway: vi.fn(async ({ method }: { method: string }) => {
- if (method === "agent") {
- return { runId: "run-456" };
- }
- return {};
- }),
- };
-});
-
-vi.mock("../plugins/hook-runner-global.js", () => ({
- getGlobalHookRunner: () => null,
-}));
-
-type GatewayCall = { method: string; params?: Record };
-
-async function getGatewayCalls(): Promise {
- const { callGateway } = await import("../gateway/call.js");
- return (callGateway as unknown as ReturnType).mock.calls.map(
- (call) => call[0] as GatewayCall,
- );
+function configureDefaultsWithoutTimeout() {
+ setSessionsSpawnConfigOverride({
+ session: { mainKey: "main", scope: "per-sender" },
+ agents: { defaults: { subagents: { maxConcurrent: 8 } } },
+ routing: { sessions: { mainKey: MAIN_SESSION_KEY } },
+ });
}
-function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
- for (let i = calls.length - 1; i >= 0; i -= 1) {
- const call = calls[i];
- if (call && predicate(call)) {
- return call;
+function readSpawnTimeout(calls: Array<{ method?: string; params?: unknown }>): number | undefined {
+ const spawn = calls.find((entry) => {
+ if (entry.method !== "agent") {
+ return false;
}
- }
- return undefined;
+ const params = entry.params as { lane?: string } | undefined;
+ return params?.lane === "subagent";
+ });
+ const params = spawn?.params as { timeout?: number } | undefined;
+ return params?.timeout;
}
describe("sessions_spawn default runTimeoutSeconds (config absent)", () => {
+ beforeEach(() => {
+ resetSessionsSpawnConfigOverride();
+ resetSubagentRegistryForTests();
+ getCallGatewayMock().mockClear();
+ });
+
it("falls back to 0 (no timeout) when config key is absent", async () => {
- const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
+ configureDefaultsWithoutTimeout();
+ const gateway = setupSessionsSpawnGatewayMock({});
+ const tool = await getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY });
+
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");
- expect(agentCall?.params?.timeout).toBe(0);
+ expect(readSpawnTimeout(gateway.calls)).toBe(0);
});
});
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts
index 8186b8bde95..cd64fc55fa7 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts
@@ -1,79 +1,61 @@
-import { describe, expect, it, vi } from "vitest";
-import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
+import { beforeEach, describe, expect, it } from "vitest";
+import "./test-helpers/fast-core-tools.js";
+import * as sessionsHarness from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
+import { resetSubagentRegistryForTests } from "./subagent-registry.js";
-vi.mock("../config/config.js", async () => {
- const actual = await vi.importActual("../config/config.js");
- return {
- ...actual,
- loadConfig: () => ({
- agents: {
- defaults: {
- subagents: {
- runTimeoutSeconds: 900,
- },
- },
- },
- routing: {
- sessions: {
- mainKey: "agent:test:main",
- },
- },
- }),
- };
-});
+const MAIN_SESSION_KEY = "agent:test:main";
-vi.mock("../gateway/call.js", () => {
- return {
- callGateway: vi.fn(async ({ method }: { method: string }) => {
- if (method === "agent") {
- return { runId: "run-123" };
- }
- return {};
- }),
- };
-});
-
-vi.mock("../plugins/hook-runner-global.js", () => ({
- getGlobalHookRunner: () => null,
-}));
-
-type GatewayCall = { method: string; params?: Record };
-
-async function getGatewayCalls(): Promise {
- const { callGateway } = await import("../gateway/call.js");
- return (callGateway as unknown as ReturnType).mock.calls.map(
- (call) => call[0] as GatewayCall,
- );
+function applySubagentTimeoutDefault(seconds: number) {
+ sessionsHarness.setSessionsSpawnConfigOverride({
+ session: { mainKey: "main", scope: "per-sender" },
+ agents: { defaults: { subagents: { runTimeoutSeconds: seconds } } },
+ routing: { sessions: { mainKey: MAIN_SESSION_KEY } },
+ });
}
-function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) {
- for (let i = calls.length - 1; i >= 0; i -= 1) {
- const call = calls[i];
- if (call && predicate(call)) {
- return call;
+function getSubagentTimeout(
+ calls: Array<{ method?: string; params?: unknown }>,
+): number | undefined {
+ for (const call of calls) {
+ if (call.method !== "agent") {
+ continue;
+ }
+ const params = call.params as { lane?: string; timeout?: number } | undefined;
+ if (params?.lane === "subagent") {
+ return params.timeout;
}
}
return undefined;
}
-describe("sessions_spawn default runTimeoutSeconds", () => {
- it("uses config default when agent omits runTimeoutSeconds", async () => {
- const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
- const result = await tool.execute("call-1", { task: "hello" });
- expect(result.details).toMatchObject({ status: "accepted" });
+async function spawnSubagent(callId: string, payload: Record) {
+ const tool = await sessionsHarness.getSessionsSpawnTool({ agentSessionKey: MAIN_SESSION_KEY });
+ const result = await tool.execute(callId, payload);
+ expect(result.details).toMatchObject({ status: "accepted" });
+}
- const calls = await getGatewayCalls();
- const agentCall = findLastCall(calls, (call) => call.method === "agent");
- expect(agentCall?.params?.timeout).toBe(900);
+describe("sessions_spawn default runTimeoutSeconds", () => {
+ beforeEach(() => {
+ sessionsHarness.resetSessionsSpawnConfigOverride();
+ resetSubagentRegistryForTests();
+ sessionsHarness.getCallGatewayMock().mockClear();
+ });
+
+ it("uses config default when agent omits runTimeoutSeconds", async () => {
+ applySubagentTimeoutDefault(900);
+ const gateway = sessionsHarness.setupSessionsSpawnGatewayMock({});
+
+ await spawnSubagent("call-1", { task: "hello" });
+
+ expect(getSubagentTimeout(gateway.calls)).toBe(900);
});
it("explicit runTimeoutSeconds wins over config default", async () => {
- const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" });
- const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 });
- expect(result.details).toMatchObject({ status: "accepted" });
+ applySubagentTimeoutDefault(900);
+ const gateway = sessionsHarness.setupSessionsSpawnGatewayMock({});
- const calls = await getGatewayCalls();
- const agentCall = findLastCall(calls, (call) => call.method === "agent");
- expect(agentCall?.params?.timeout).toBe(300);
+ await spawnSubagent("call-2", { task: "hello", runTimeoutSeconds: 300 });
+
+ expect(getSubagentTimeout(gateway.calls)).toBe(300);
});
});
diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
index e3061518f2d..33c85b832e5 100644
--- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
+++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
@@ -320,54 +320,55 @@ describe("downgradeOpenAIReasoningBlocks", () => {
});
describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
+ const callIdWithReasoning = "call_123|fc_123";
+ const callIdWithoutReasoning = "call_123";
+ const readArgs = {} as Record;
+
+ const makeToolCall = (id: string) => ({
+ type: "toolCall",
+ id,
+ name: "read",
+ arguments: readArgs,
+ });
+ const makeToolResult = (toolCallId: string, text: string) => ({
+ role: "toolResult",
+ toolCallId,
+ toolName: "read",
+ content: [{ type: "text", text }],
+ });
+ const makeReasoningAssistantTurn = (id: string) => ({
+ role: "assistant",
+ content: [
+ {
+ type: "thinking",
+ thinking: "internal",
+ thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
+ },
+ makeToolCall(id),
+ ],
+ });
+ const makePlainAssistantTurn = (id: string) => ({
+ role: "assistant",
+ content: [makeToolCall(id)],
+ });
+
it("strips fc ids when reasoning cannot be replayed", () => {
const input = [
- {
- role: "assistant",
- content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }],
- },
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "read",
- content: [{ type: "text", text: "ok" }],
- },
+ makePlainAssistantTurn(callIdWithReasoning),
+ makeToolResult(callIdWithReasoning, "ok"),
];
// oxlint-disable-next-line typescript/no-explicit-any
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
- {
- role: "assistant",
- content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }],
- },
- {
- role: "toolResult",
- toolCallId: "call_123",
- toolName: "read",
- content: [{ type: "text", text: "ok" }],
- },
+ makePlainAssistantTurn(callIdWithoutReasoning),
+ makeToolResult(callIdWithoutReasoning, "ok"),
]);
});
it("keeps fc ids when replayable reasoning is present", () => {
const input = [
- {
- role: "assistant",
- content: [
- {
- type: "thinking",
- thinking: "internal",
- thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
- },
- { type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
- ],
- },
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "read",
- content: [{ type: "text", text: "ok" }],
- },
+ makeReasoningAssistantTurn(callIdWithReasoning),
+ makeToolResult(callIdWithReasoning, "ok"),
];
// oxlint-disable-next-line typescript/no-explicit-any
@@ -376,64 +377,18 @@ describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
it("only rewrites tool results paired to the downgraded assistant turn", () => {
const input = [
- {
- role: "assistant",
- content: [{ type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} }],
- },
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "read",
- content: [{ type: "text", text: "turn1" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "thinking",
- thinking: "internal",
- thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
- },
- { type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
- ],
- },
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "read",
- content: [{ type: "text", text: "turn2" }],
- },
+ makePlainAssistantTurn(callIdWithReasoning),
+ makeToolResult(callIdWithReasoning, "turn1"),
+ makeReasoningAssistantTurn(callIdWithReasoning),
+ makeToolResult(callIdWithReasoning, "turn2"),
];
// oxlint-disable-next-line typescript/no-explicit-any
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
- {
- role: "assistant",
- content: [{ type: "toolCall", id: "call_123", name: "read", arguments: {} }],
- },
- {
- role: "toolResult",
- toolCallId: "call_123",
- toolName: "read",
- content: [{ type: "text", text: "turn1" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "thinking",
- thinking: "internal",
- thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
- },
- { type: "toolCall", id: "call_123|fc_123", name: "read", arguments: {} },
- ],
- },
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "read",
- content: [{ type: "text", text: "turn2" }],
- },
+ makePlainAssistantTurn(callIdWithoutReasoning),
+ makeToolResult(callIdWithoutReasoning, "turn1"),
+ makeReasoningAssistantTurn(callIdWithReasoning),
+ makeToolResult(callIdWithReasoning, "turn2"),
]);
});
});
diff --git a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts b/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts
index ee714903022..d0d4b7c36d2 100644
--- a/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts
+++ b/src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts
@@ -7,92 +7,66 @@ import {
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
describe("sanitizeSessionHistory openai tool id preservation", () => {
- it("strips fc ids when replayable reasoning metadata is missing", async () => {
- const sessionEntries = [
+ const makeSessionManager = () =>
+ makeInMemorySessionManager([
makeModelSnapshotEntry({
provider: "openai",
modelApi: "openai-responses",
modelId: "gpt-5.2-codex",
}),
- ];
- const sessionManager = makeInMemorySessionManager(sessionEntries);
+ ]);
- const messages: AgentMessage[] = [
- {
- role: "assistant",
- content: [{ type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} }],
- } as unknown as AgentMessage,
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "noop",
- content: [{ type: "text", text: "ok" }],
- isError: false,
- } as unknown as AgentMessage,
- ];
+ const makeMessages = (withReasoning: boolean): AgentMessage[] => [
+ {
+ role: "assistant",
+ content: [
+ ...(withReasoning
+ ? [
+ {
+ type: "thinking",
+ thinking: "internal reasoning",
+ thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
+ },
+ ]
+ : []),
+ { type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
+ ],
+ } as unknown as AgentMessage,
+ {
+ role: "toolResult",
+ toolCallId: "call_123|fc_123",
+ toolName: "noop",
+ content: [{ type: "text", text: "ok" }],
+ isError: false,
+ } as unknown as AgentMessage,
+ ];
+ it.each([
+ {
+ name: "strips fc ids when replayable reasoning metadata is missing",
+ withReasoning: false,
+ expectedToolId: "call_123",
+ },
+ {
+ name: "keeps canonical call_id|fc_id pairings when replayable reasoning is present",
+ withReasoning: true,
+ expectedToolId: "call_123|fc_123",
+ },
+ ])("$name", async ({ withReasoning, expectedToolId }) => {
const result = await sanitizeSessionHistory({
- messages,
+ messages: makeMessages(withReasoning),
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
- sessionManager,
+ sessionManager: makeSessionManager(),
sessionId: "test-session",
});
const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
const toolCall = assistant.content?.find((block) => block.type === "toolCall");
- expect(toolCall?.id).toBe("call_123");
+ expect(toolCall?.id).toBe(expectedToolId);
const toolResult = result[1] as { toolCallId?: string };
- expect(toolResult.toolCallId).toBe("call_123");
- });
-
- it("keeps canonical call_id|fc_id pairings when replayable reasoning is present", async () => {
- const sessionEntries = [
- makeModelSnapshotEntry({
- provider: "openai",
- modelApi: "openai-responses",
- modelId: "gpt-5.2-codex",
- }),
- ];
- const sessionManager = makeInMemorySessionManager(sessionEntries);
-
- const messages: AgentMessage[] = [
- {
- role: "assistant",
- content: [
- {
- type: "thinking",
- thinking: "internal reasoning",
- thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
- },
- { type: "toolCall", id: "call_123|fc_123", name: "noop", arguments: {} },
- ],
- } as unknown as AgentMessage,
- {
- role: "toolResult",
- toolCallId: "call_123|fc_123",
- toolName: "noop",
- content: [{ type: "text", text: "ok" }],
- isError: false,
- } as unknown as AgentMessage,
- ];
-
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- modelId: "gpt-5.2-codex",
- sessionManager,
- sessionId: "test-session",
- });
-
- const assistant = result[0] as { content?: Array<{ type?: string; id?: string }> };
- const toolCall = assistant.content?.find((block) => block.type === "toolCall");
- expect(toolCall?.id).toBe("call_123|fc_123");
-
- const toolResult = result[1] as { toolCallId?: string };
- expect(toolResult.toolCallId).toBe("call_123|fc_123");
+ expect(toolResult.toolCallId).toBe(expectedToolId);
});
});
diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts
index fc1a2cec801..6b65bc9d3be 100644
--- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts
+++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts
@@ -74,6 +74,54 @@ describe("sanitizeSessionHistory", () => {
},
] as unknown as AgentMessage[];
+ const makeUsage = (input: number, output: number, totalTokens: number) => ({
+ input,
+ output,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ });
+
+ const makeAssistantUsageMessage = (params: {
+ text: string;
+ usage: ReturnType;
+ timestamp?: number;
+ }) =>
+ ({
+ role: "assistant",
+ content: [{ type: "text", text: params.text }],
+ stopReason: "stop",
+ ...(typeof params.timestamp === "number" ? { timestamp: params.timestamp } : {}),
+ usage: params.usage,
+ }) as unknown as AgentMessage;
+
+ const makeCompactionSummaryMessage = (tokensBefore: number, timestamp: string) =>
+ ({
+ role: "compactionSummary",
+ summary: "compressed",
+ tokensBefore,
+ timestamp,
+ }) as unknown as AgentMessage;
+
+ const sanitizeOpenAIHistory = async (
+ messages: AgentMessage[],
+ overrides: Partial[0]> = {},
+ ) =>
+ sanitizeSessionHistory({
+ messages,
+ modelApi: "openai-responses",
+ provider: "openai",
+ sessionManager: mockSessionManager,
+ sessionId: TEST_SESSION_ID,
+ ...overrides,
+ });
+
+ const getAssistantMessages = (messages: AgentMessage[]) =>
+ messages.filter((message) => message.role === "assistant") as Array<
+ AgentMessage & { usage?: unknown; content?: unknown }
+ >;
+
beforeEach(async () => {
sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks();
});
@@ -178,34 +226,14 @@ describe("sanitizeSessionHistory", () => {
const messages = [
{ role: "user", content: "old context" },
- {
- role: "assistant",
- content: [{ type: "text", text: "old answer" }],
- stopReason: "stop",
- usage: {
- input: 191_919,
- output: 2_000,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 193_919,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- },
- {
- role: "compactionSummary",
- summary: "compressed",
- tokensBefore: 191_919,
- timestamp: new Date().toISOString(),
- },
+ makeAssistantUsageMessage({
+ text: "old answer",
+ usage: makeUsage(191_919, 2_000, 193_919),
+ }),
+ makeCompactionSummaryMessage(191_919, new Date().toISOString()),
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
- });
+ const result = await sanitizeOpenAIHistory(messages);
const staleAssistant = result.find((message) => message.role === "assistant") as
| (AgentMessage & { usage?: unknown })
@@ -218,52 +246,21 @@ describe("sanitizeSessionHistory", () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
const messages = [
- {
- role: "assistant",
- content: [{ type: "text", text: "pre-compaction answer" }],
- stopReason: "stop",
- usage: {
- input: 120_000,
- output: 3_000,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 123_000,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- },
- {
- role: "compactionSummary",
- summary: "compressed",
- tokensBefore: 123_000,
- timestamp: new Date().toISOString(),
- },
+ makeAssistantUsageMessage({
+ text: "pre-compaction answer",
+ usage: makeUsage(120_000, 3_000, 123_000),
+ }),
+ makeCompactionSummaryMessage(123_000, new Date().toISOString()),
{ role: "user", content: "new question" },
- {
- role: "assistant",
- content: [{ type: "text", text: "fresh answer" }],
- stopReason: "stop",
- usage: {
- input: 1_000,
- output: 250,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 1_250,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- },
+ makeAssistantUsageMessage({
+ text: "fresh answer",
+ usage: makeUsage(1_000, 250, 1_250),
+ }),
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
- });
+ const result = await sanitizeOpenAIHistory(messages);
- const assistants = result.filter((message) => message.role === "assistant") as Array<
- AgentMessage & { usage?: unknown }
- >;
+ const assistants = getAssistantMessages(result);
expect(assistants).toHaveLength(2);
expect(assistants[0]?.usage).toEqual(makeZeroUsageSnapshot());
expect(assistants[1]?.usage).toBeDefined();
@@ -274,35 +271,15 @@ describe("sanitizeSessionHistory", () => {
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = [
- {
- role: "compactionSummary",
- summary: "compressed",
- tokensBefore: 191_919,
- timestamp: new Date(compactionTs).toISOString(),
- },
- {
- role: "assistant",
- content: [{ type: "text", text: "kept pre-compaction answer" }],
- stopReason: "stop",
+ makeCompactionSummaryMessage(191_919, new Date(compactionTs).toISOString()),
+ makeAssistantUsageMessage({
+ text: "kept pre-compaction answer",
timestamp: compactionTs - 1_000,
- usage: {
- input: 191_919,
- output: 2_000,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 193_919,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- },
+ usage: makeUsage(191_919, 2_000, 193_919),
+ }),
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
- });
+ const result = await sanitizeOpenAIHistory(messages);
const assistant = result.find((message) => message.role === "assistant") as
| (AgentMessage & { usage?: unknown })
@@ -315,54 +292,23 @@ describe("sanitizeSessionHistory", () => {
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
const messages = [
- {
- role: "compactionSummary",
- summary: "compressed",
- tokensBefore: 123_000,
- timestamp: new Date(compactionTs).toISOString(),
- },
- {
- role: "assistant",
- content: [{ type: "text", text: "kept pre-compaction answer" }],
- stopReason: "stop",
+ makeCompactionSummaryMessage(123_000, new Date(compactionTs).toISOString()),
+ makeAssistantUsageMessage({
+ text: "kept pre-compaction answer",
timestamp: compactionTs - 2_000,
- usage: {
- input: 120_000,
- output: 3_000,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 123_000,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- },
+ usage: makeUsage(120_000, 3_000, 123_000),
+ }),
{ role: "user", content: "new question", timestamp: compactionTs + 1_000 },
- {
- role: "assistant",
- content: [{ type: "text", text: "fresh answer" }],
- stopReason: "stop",
+ makeAssistantUsageMessage({
+ text: "fresh answer",
timestamp: compactionTs + 2_000,
- usage: {
- input: 1_000,
- output: 250,
- cacheRead: 0,
- cacheWrite: 0,
- totalTokens: 1_250,
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
- },
- },
+ usage: makeUsage(1_000, 250, 1_250),
+ }),
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
- });
+ const result = await sanitizeOpenAIHistory(messages);
- const assistants = result.filter((message) => message.role === "assistant") as Array<
- AgentMessage & { usage?: unknown; content?: unknown }
- >;
+ const assistants = getAssistantMessages(result);
const keptAssistant = assistants.find((message) =>
JSON.stringify(message.content).includes("kept pre-compaction answer"),
);
@@ -411,13 +357,7 @@ describe("sanitizeSessionHistory", () => {
},
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
- });
+ const result = await sanitizeOpenAIHistory(messages);
// repairToolUseResultPairing now runs for all providers (including OpenAI)
// to fix orphaned function_call_output items that OpenAI would reject.
@@ -435,13 +375,7 @@ describe("sanitizeSessionHistory", () => {
{ role: "user", content: "hello" },
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: "test-session",
- });
+ const result = await sanitizeOpenAIHistory(messages, { sessionId: "test-session" });
expect(result.map((msg) => msg.role)).toEqual(["user"]);
});
@@ -463,13 +397,7 @@ describe("sanitizeSessionHistory", () => {
{ role: "user", content: "hello" },
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
- });
+ const result = await sanitizeOpenAIHistory(messages);
expect(result.map((msg) => msg.role)).toEqual(["user"]);
});
@@ -482,13 +410,8 @@ describe("sanitizeSessionHistory", () => {
},
] as unknown as AgentMessage[];
- const result = await sanitizeSessionHistory({
- messages,
- modelApi: "openai-responses",
- provider: "openai",
+ const result = await sanitizeOpenAIHistory(messages, {
allowedToolNames: ["read"],
- sessionManager: mockSessionManager,
- sessionId: TEST_SESSION_ID,
});
expect(result).toEqual([]);
diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts
index 705025eaf5a..41750595b98 100644
--- a/src/agents/pi-embedded-runner/run/attempt.test.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.test.ts
@@ -12,6 +12,21 @@ import {
wrapStreamFnTrimToolCallNames,
} from "./attempt.js";
+function createOllamaProviderConfig(injectNumCtxForOpenAICompat: boolean): OpenClawConfig {
+ return {
+ models: {
+ providers: {
+ ollama: {
+ baseUrl: "http://127.0.0.1:11434/v1",
+ api: "openai-completions",
+ injectNumCtxForOpenAICompat,
+ models: [],
+ },
+ },
+ },
+ };
+}
+
describe("resolvePromptBuildHookResult", () => {
function createLegacyOnlyHookRunner() {
return {
@@ -129,6 +144,25 @@ describe("wrapStreamFnTrimToolCallNames", () => {
};
}
+ async function invokeWrappedStream(
+ baseFn: (...args: never[]) => unknown,
+ allowedToolNames?: Set,
+ ) {
+ const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, allowedToolNames);
+ return await wrappedFn({} as never, {} as never, {} as never);
+ }
+
+ function createEventStream(params: {
+ event: unknown;
+ finalToolCall: { type: string; name: string };
+ }) {
+ const finalMessage = { role: "assistant", content: [params.finalToolCall] };
+ const baseFn = vi.fn(() =>
+ createFakeStream({ events: [params.event], resultMessage: finalMessage }),
+ );
+ return { baseFn, finalMessage };
+ }
+
it("trims whitespace from live streamed tool call names and final result message", async () => {
const partialToolCall = { type: "toolCall", name: " read " };
const messageToolCall = { type: "toolCall", name: " exec " };
@@ -138,13 +172,9 @@ describe("wrapStreamFnTrimToolCallNames", () => {
partial: { role: "assistant", content: [partialToolCall] },
message: { role: "assistant", content: [messageToolCall] },
};
- const finalMessage = { role: "assistant", content: [finalToolCall] };
- const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage }));
+ const { baseFn, finalMessage } = createEventStream({ event, finalToolCall });
- const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
- const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
- ReturnType
- >;
+ const stream = await invokeWrappedStream(baseFn);
const seenEvents: unknown[] = [];
for await (const item of stream) {
@@ -170,8 +200,7 @@ describe("wrapStreamFnTrimToolCallNames", () => {
}),
);
- const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
- const stream = await wrappedFn({} as never, {} as never, {} as never);
+ const stream = await invokeWrappedStream(baseFn);
const result = await stream.result();
expect(finalToolCall.name).toBe("browser");
@@ -188,10 +217,7 @@ describe("wrapStreamFnTrimToolCallNames", () => {
}),
);
- const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never, new Set(["exec"]));
- const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
- ReturnType
- >;
+ const stream = await invokeWrappedStream(baseFn, new Set(["exec"]));
const result = await stream.result();
expect(finalToolCall.name).toBe("exec");
@@ -205,13 +231,9 @@ describe("wrapStreamFnTrimToolCallNames", () => {
type: "toolcall_delta",
partial: { role: "assistant", content: [partialToolCall] },
};
- const finalMessage = { role: "assistant", content: [finalToolCall] };
- const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage }));
+ const { baseFn } = createEventStream({ event, finalToolCall });
- const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
- const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
- ReturnType
- >;
+ const stream = await invokeWrappedStream(baseFn);
for await (const _item of stream) {
// drain
@@ -346,18 +368,7 @@ describe("resolveOllamaCompatNumCtxEnabled", () => {
it("returns false when provider flag is explicitly disabled", () => {
expect(
resolveOllamaCompatNumCtxEnabled({
- config: {
- models: {
- providers: {
- ollama: {
- baseUrl: "http://127.0.0.1:11434/v1",
- api: "openai-completions",
- injectNumCtxForOpenAICompat: false,
- models: [],
- },
- },
- },
- },
+ config: createOllamaProviderConfig(false),
providerId: "ollama",
}),
).toBe(false);
@@ -385,18 +396,7 @@ describe("shouldInjectOllamaCompatNumCtx", () => {
api: "openai-completions",
baseUrl: "http://127.0.0.1:11434/v1",
},
- config: {
- models: {
- providers: {
- ollama: {
- baseUrl: "http://127.0.0.1:11434/v1",
- api: "openai-completions",
- injectNumCtxForOpenAICompat: false,
- models: [],
- },
- },
- },
- },
+ config: createOllamaProviderConfig(false),
providerId: "ollama",
}),
).toBe(false);
diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts
index bb673898a24..8e9defdba09 100644
--- a/src/agents/sandbox/fs-bridge.test.ts
+++ b/src/agents/sandbox/fs-bridge.test.ts
@@ -36,6 +36,14 @@ function findCallByScriptFragment(fragment: string) {
return mockedExecDockerRaw.mock.calls.find(([args]) => getDockerScript(args).includes(fragment));
}
+function dockerExecResult(stdout: string) {
+ return {
+ stdout: Buffer.from(stdout),
+ stderr: Buffer.alloc(0),
+ code: 0,
+ };
+}
+
function createSandbox(overrides?: Partial): SandboxContext {
return createSandboxTestContext({
overrides: {
@@ -58,38 +66,37 @@ async function withTempDir(prefix: string, run: (stateDir: string) => Promise
}
}
+function installDockerReadMock(params?: { canonicalPath?: string }) {
+ const canonicalPath = params?.canonicalPath;
+ mockedExecDockerRaw.mockImplementation(async (args) => {
+ const script = getDockerScript(args);
+ if (script.includes('readlink -f -- "$cursor"')) {
+ return dockerExecResult(`${canonicalPath ?? getDockerArg(args, 1)}\n`);
+ }
+ if (script.includes('stat -c "%F|%s|%Y"')) {
+ return dockerExecResult("regular file|1|2");
+ }
+ if (script.includes('cat -- "$1"')) {
+ return dockerExecResult("content");
+ }
+ return dockerExecResult("");
+ });
+}
+
+async function createHostEscapeFixture(stateDir: string) {
+ const workspaceDir = path.join(stateDir, "workspace");
+ const outsideDir = path.join(stateDir, "outside");
+ const outsideFile = path.join(outsideDir, "secret.txt");
+ await fs.mkdir(workspaceDir, { recursive: true });
+ await fs.mkdir(outsideDir, { recursive: true });
+ await fs.writeFile(outsideFile, "classified");
+ return { workspaceDir, outsideFile };
+}
+
describe("sandbox fs bridge shell compatibility", () => {
beforeEach(() => {
mockedExecDockerRaw.mockClear();
- mockedExecDockerRaw.mockImplementation(async (args) => {
- const script = getDockerScript(args);
- if (script.includes('readlink -f -- "$cursor"')) {
- return {
- stdout: Buffer.from(`${getDockerArg(args, 1)}\n`),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- }
- if (script.includes('stat -c "%F|%s|%Y"')) {
- return {
- stdout: Buffer.from("regular file|1|2"),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- }
- if (script.includes('cat -- "$1"')) {
- return {
- stdout: Buffer.from("content"),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- }
- return {
- stdout: Buffer.alloc(0),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- });
+ installDockerReadMock();
});
it("uses POSIX-safe shell prologue in all bridge commands", async () => {
@@ -227,12 +234,7 @@ describe("sandbox fs bridge shell compatibility", () => {
it("rejects pre-existing host symlink escapes before docker exec", async () => {
await withTempDir("openclaw-fs-bridge-", async (stateDir) => {
- const workspaceDir = path.join(stateDir, "workspace");
- const outsideDir = path.join(stateDir, "outside");
- const outsideFile = path.join(outsideDir, "secret.txt");
- await fs.mkdir(workspaceDir, { recursive: true });
- await fs.mkdir(outsideDir, { recursive: true });
- await fs.writeFile(outsideFile, "classified");
+ const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir);
await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt"));
const bridge = createSandboxFsBridge({
@@ -252,12 +254,7 @@ describe("sandbox fs bridge shell compatibility", () => {
return;
}
await withTempDir("openclaw-fs-bridge-hardlink-", async (stateDir) => {
- const workspaceDir = path.join(stateDir, "workspace");
- const outsideDir = path.join(stateDir, "outside");
- const outsideFile = path.join(outsideDir, "secret.txt");
- await fs.mkdir(workspaceDir, { recursive: true });
- await fs.mkdir(outsideDir, { recursive: true });
- await fs.writeFile(outsideFile, "classified");
+ const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir);
const hardlinkPath = path.join(workspaceDir, "link.txt");
try {
await fs.link(outsideFile, hardlinkPath);
@@ -281,28 +278,7 @@ describe("sandbox fs bridge shell compatibility", () => {
});
it("rejects container-canonicalized paths outside allowed mounts", async () => {
- mockedExecDockerRaw.mockImplementation(async (args) => {
- const script = getDockerScript(args);
- if (script.includes('readlink -f -- "$cursor"')) {
- return {
- stdout: Buffer.from("/etc/passwd\n"),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- }
- if (script.includes('cat -- "$1"')) {
- return {
- stdout: Buffer.from("content"),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- }
- return {
- stdout: Buffer.alloc(0),
- stderr: Buffer.alloc(0),
- code: 0,
- };
- });
+ installDockerReadMock({ canonicalPath: "/etc/passwd" });
const bridge = createSandboxFsBridge({ sandbox: createSandbox() });
await expect(bridge.readFile({ filePath: "a.txt" })).rejects.toThrow(/escapes allowed mounts/i);
diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts
index daadbca253e..2c493fc0dc2 100644
--- a/src/agents/session-transcript-repair.test.ts
+++ b/src/agents/session-transcript-repair.test.ts
@@ -239,6 +239,28 @@ describe("sanitizeToolUseResultPairing", () => {
});
describe("sanitizeToolCallInputs", () => {
+ function sanitizeAssistantContent(
+ content: unknown[],
+ options?: Parameters[1],
+ ) {
+ return sanitizeToolCallInputs(
+ [
+ {
+ role: "assistant",
+ content,
+ },
+ ] as unknown as AgentMessage[],
+ options,
+ );
+ }
+
+ function sanitizeAssistantToolCalls(
+ content: unknown[],
+ options?: Parameters[1],
+ ) {
+ return getAssistantToolCallBlocks(sanitizeAssistantContent(content, options));
+ }
+
it("drops tool calls missing input or arguments", () => {
const input = [
{
@@ -252,71 +274,54 @@ describe("sanitizeToolCallInputs", () => {
expect(out.map((m) => m.role)).toEqual(["user"]);
});
- it("drops tool calls with missing or blank name/id", () => {
- const input = [
- {
- role: "assistant",
- content: [
- { type: "toolCall", id: "call_ok", name: "read", arguments: {} },
- { type: "toolCall", id: "call_empty_name", name: "", arguments: {} },
- { type: "toolUse", id: "call_blank_name", name: " ", input: {} },
- { type: "functionCall", id: "", name: "exec", arguments: {} },
- ],
- },
- ] as unknown as AgentMessage[];
+ it.each([
+ {
+ name: "drops tool calls with missing or blank name/id",
+ content: [
+ { type: "toolCall", id: "call_ok", name: "read", arguments: {} },
+ { type: "toolCall", id: "call_empty_name", name: "", arguments: {} },
+ { type: "toolUse", id: "call_blank_name", name: " ", input: {} },
+ { type: "functionCall", id: "", name: "exec", arguments: {} },
+ ],
+ options: undefined,
+ expectedIds: ["call_ok"],
+ },
+ {
+ name: "drops tool calls with malformed or overlong names",
+ content: [
+ { type: "toolCall", id: "call_ok", name: "read", arguments: {} },
+ {
+ type: "toolCall",
+ id: "call_bad_chars",
+ name: 'toolu_01abc <|tool_call_argument_begin|> {"command"',
+ arguments: {},
+ },
+ {
+ type: "toolUse",
+ id: "call_too_long",
+ name: `read_${"x".repeat(80)}`,
+ input: {},
+ },
+ ],
+ options: undefined,
+ expectedIds: ["call_ok"],
+ },
+ {
+ name: "drops unknown tool names when an allowlist is provided",
+ content: [
+ { type: "toolCall", id: "call_ok", name: "read", arguments: {} },
+ { type: "toolCall", id: "call_unknown", name: "write", arguments: {} },
+ ],
+ options: { allowedToolNames: ["read"] },
+ expectedIds: ["call_ok"],
+ },
+ ])("$name", ({ content, options, expectedIds }) => {
+ const toolCalls = sanitizeAssistantToolCalls(content, options);
+ const ids = toolCalls
+ .map((toolCall) => (toolCall as { id?: unknown }).id)
+ .filter((id): id is string => typeof id === "string");
- const out = sanitizeToolCallInputs(input);
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(1);
- expect((toolCalls[0] as { id?: unknown }).id).toBe("call_ok");
- });
-
- it("drops tool calls with malformed or overlong names", () => {
- const input = [
- {
- role: "assistant",
- content: [
- { type: "toolCall", id: "call_ok", name: "read", arguments: {} },
- {
- type: "toolCall",
- id: "call_bad_chars",
- name: 'toolu_01abc <|tool_call_argument_begin|> {"command"',
- arguments: {},
- },
- {
- type: "toolUse",
- id: "call_too_long",
- name: `read_${"x".repeat(80)}`,
- input: {},
- },
- ],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input);
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(1);
- expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
- });
-
- it("drops unknown tool names when an allowlist is provided", () => {
- const input = [
- {
- role: "assistant",
- content: [
- { type: "toolCall", id: "call_ok", name: "read", arguments: {} },
- { type: "toolCall", id: "call_unknown", name: "write", arguments: {} },
- ],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(1);
- expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
+ expect(ids).toEqual(expectedIds);
});
it("keeps valid tool calls and preserves text blocks", () => {
@@ -339,71 +344,43 @@ describe("sanitizeToolCallInputs", () => {
expect(types).toEqual(["text", "toolUse"]);
});
- it("trims leading whitespace from tool names", () => {
- const input = [
- {
- role: "assistant",
- content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input);
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(1);
- expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
- });
-
- it("trims trailing whitespace from tool names", () => {
- const input = [
- {
- role: "assistant",
- content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input);
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(1);
- expect((toolCalls[0] as { name?: unknown }).name).toBe("exec");
- });
-
- it("trims both leading and trailing whitespace from tool names", () => {
- const input = [
- {
- role: "assistant",
- content: [
- { type: "toolCall", id: "call_1", name: " read ", arguments: {} },
- { type: "toolUse", id: "call_2", name: " exec ", input: {} },
- ],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input);
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(2);
- expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
- expect((toolCalls[1] as { name?: unknown }).name).toBe("exec");
- });
-
- it("trims tool names and matches against allowlist", () => {
- const input = [
- {
- role: "assistant",
- content: [
- { type: "toolCall", id: "call_1", name: " read ", arguments: {} },
- { type: "toolCall", id: "call_2", name: " write ", arguments: {} },
- ],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
- const toolCalls = getAssistantToolCallBlocks(out);
-
- expect(toolCalls).toHaveLength(1);
- expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
+ it.each([
+ {
+ name: "trims leading whitespace from tool names",
+ content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }],
+ options: undefined,
+ expectedNames: ["read"],
+ },
+ {
+ name: "trims trailing whitespace from tool names",
+ content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }],
+ options: undefined,
+ expectedNames: ["exec"],
+ },
+ {
+ name: "trims both leading and trailing whitespace from tool names",
+ content: [
+ { type: "toolCall", id: "call_1", name: " read ", arguments: {} },
+ { type: "toolUse", id: "call_2", name: " exec ", input: {} },
+ ],
+ options: undefined,
+ expectedNames: ["read", "exec"],
+ },
+ {
+ name: "trims tool names and matches against allowlist",
+ content: [
+ { type: "toolCall", id: "call_1", name: " read ", arguments: {} },
+ { type: "toolCall", id: "call_2", name: " write ", arguments: {} },
+ ],
+ options: { allowedToolNames: ["read"] },
+ expectedNames: ["read"],
+ },
+ ])("$name", ({ content, options, expectedNames }) => {
+ const toolCalls = sanitizeAssistantToolCalls(content, options);
+ const names = toolCalls
+ .map((toolCall) => (toolCall as { name?: unknown }).name)
+ .filter((name): name is string => typeof name === "string");
+ expect(names).toEqual(expectedNames);
});
it("preserves toolUse input shape for sessions_spawn when no attachments are present", () => {
@@ -458,17 +435,9 @@ describe("sanitizeToolCallInputs", () => {
expect(attachments[0]?.content).toBe("__OPENCLAW_REDACTED__");
});
it("preserves other block properties when trimming tool names", () => {
- const input = [
- {
- role: "assistant",
- content: [
- { type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } },
- ],
- },
- ] as unknown as AgentMessage[];
-
- const out = sanitizeToolCallInputs(input);
- const toolCalls = getAssistantToolCallBlocks(out);
+ const toolCalls = sanitizeAssistantToolCalls([
+ { type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } },
+ ]);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts
index 86a49080256..fd3abd6d07d 100644
--- a/src/agents/skills/plugin-skills.test.ts
+++ b/src/agents/skills/plugin-skills.test.ts
@@ -47,85 +47,93 @@ function buildRegistry(params: { acpxRoot: string; helperRoot: string }): Plugin
};
}
+function createSinglePluginRegistry(params: {
+ pluginRoot: string;
+ skills: string[];
+}): PluginManifestRegistry {
+ return {
+ diagnostics: [],
+ plugins: [
+ {
+ id: "helper",
+ name: "Helper",
+ channels: [],
+ providers: [],
+ skills: params.skills,
+ origin: "workspace",
+ rootDir: params.pluginRoot,
+ source: params.pluginRoot,
+ manifestPath: path.join(params.pluginRoot, "openclaw.plugin.json"),
+ },
+ ],
+ };
+}
+
+async function setupAcpxAndHelperRegistry() {
+ const workspaceDir = await tempDirs.make("openclaw-");
+ const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
+ const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
+ await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
+ await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
+ hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ acpxRoot, helperRoot }));
+ return { workspaceDir, acpxRoot, helperRoot };
+}
+
+async function setupPluginOutsideSkills() {
+ const workspaceDir = await tempDirs.make("openclaw-");
+ const pluginRoot = await tempDirs.make("openclaw-plugin-");
+ const outsideDir = await tempDirs.make("openclaw-outside-");
+ const outsideSkills = path.join(outsideDir, "skills");
+ return { workspaceDir, pluginRoot, outsideSkills };
+}
+
afterEach(async () => {
hoisted.loadPluginManifestRegistry.mockReset();
await tempDirs.cleanup();
});
describe("resolvePluginSkillDirs", () => {
- it("keeps acpx plugin skills when ACP is enabled", async () => {
- const workspaceDir = await tempDirs.make("openclaw-");
- const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
- const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
- await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
- await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
-
- hoisted.loadPluginManifestRegistry.mockReturnValue(
- buildRegistry({
- acpxRoot,
- helperRoot,
- }),
- );
+ it.each([
+ {
+ name: "keeps acpx plugin skills when ACP is enabled",
+ acpEnabled: true,
+ expectedDirs: ({ acpxRoot, helperRoot }: { acpxRoot: string; helperRoot: string }) => [
+ path.resolve(acpxRoot, "skills"),
+ path.resolve(helperRoot, "skills"),
+ ],
+ },
+ {
+ name: "skips acpx plugin skills when ACP is disabled",
+ acpEnabled: false,
+ expectedDirs: ({ helperRoot }: { acpxRoot: string; helperRoot: string }) => [
+ path.resolve(helperRoot, "skills"),
+ ],
+ },
+ ])("$name", async ({ acpEnabled, expectedDirs }) => {
+ const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry();
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {
- acp: { enabled: true },
+ acp: { enabled: acpEnabled },
} as OpenClawConfig,
});
- expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]);
- });
-
- it("skips acpx plugin skills when ACP is disabled", async () => {
- const workspaceDir = await tempDirs.make("openclaw-");
- const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
- const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
- await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
- await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
-
- hoisted.loadPluginManifestRegistry.mockReturnValue(
- buildRegistry({
- acpxRoot,
- helperRoot,
- }),
- );
-
- const dirs = resolvePluginSkillDirs({
- workspaceDir,
- config: {
- acp: { enabled: false },
- } as OpenClawConfig,
- });
-
- expect(dirs).toEqual([path.resolve(helperRoot, "skills")]);
+ expect(dirs).toEqual(expectedDirs({ acpxRoot, helperRoot }));
});
it("rejects plugin skill paths that escape the plugin root", async () => {
- const workspaceDir = await tempDirs.make("openclaw-");
- const pluginRoot = await tempDirs.make("openclaw-plugin-");
- const outsideDir = await tempDirs.make("openclaw-outside-");
- const outsideSkills = path.join(outsideDir, "skills");
+ const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills();
await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true });
await fs.mkdir(outsideSkills, { recursive: true });
const escapePath = path.relative(pluginRoot, outsideSkills);
- hoisted.loadPluginManifestRegistry.mockReturnValue({
- diagnostics: [],
- plugins: [
- {
- id: "helper",
- name: "Helper",
- channels: [],
- providers: [],
- skills: ["./skills", escapePath],
- origin: "workspace",
- rootDir: pluginRoot,
- source: pluginRoot,
- manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
- },
- ],
- } satisfies PluginManifestRegistry);
+ hoisted.loadPluginManifestRegistry.mockReturnValue(
+ createSinglePluginRegistry({
+ pluginRoot,
+ skills: ["./skills", escapePath],
+ }),
+ );
const dirs = resolvePluginSkillDirs({
workspaceDir,
@@ -136,10 +144,7 @@ describe("resolvePluginSkillDirs", () => {
});
it("rejects plugin skill symlinks that resolve outside plugin root", async () => {
- const workspaceDir = await tempDirs.make("openclaw-");
- const pluginRoot = await tempDirs.make("openclaw-plugin-");
- const outsideDir = await tempDirs.make("openclaw-outside-");
- const outsideSkills = path.join(outsideDir, "skills");
+ const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills();
const linkPath = path.join(pluginRoot, "skills-link");
await fs.mkdir(outsideSkills, { recursive: true });
await fs.symlink(
@@ -148,22 +153,12 @@ describe("resolvePluginSkillDirs", () => {
process.platform === "win32" ? ("junction" as const) : ("dir" as const),
);
- hoisted.loadPluginManifestRegistry.mockReturnValue({
- diagnostics: [],
- plugins: [
- {
- id: "helper",
- name: "Helper",
- channels: [],
- providers: [],
- skills: ["./skills-link"],
- origin: "workspace",
- rootDir: pluginRoot,
- source: pluginRoot,
- manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
- },
- ],
- } satisfies PluginManifestRegistry);
+ hoisted.loadPluginManifestRegistry.mockReturnValue(
+ createSinglePluginRegistry({
+ pluginRoot,
+ skills: ["./skills-link"],
+ }),
+ );
const dirs = resolvePluginSkillDirs({
workspaceDir,
diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts
index 7f919c4fd49..a74af80db92 100644
--- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts
+++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts
@@ -1,46 +1,54 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const noop = () => {};
+const MAIN_REQUESTER_SESSION_KEY = "agent:main:main";
+const MAIN_REQUESTER_DISPLAY_KEY = "main";
-let lifecycleHandler:
- | ((evt: {
- stream?: string;
- runId: string;
- data?: {
- phase?: string;
- startedAt?: number;
- endedAt?: number;
- aborted?: boolean;
- error?: string;
- };
- }) => void)
- | undefined;
+type LifecycleData = {
+ phase?: string;
+ startedAt?: number;
+ endedAt?: number;
+ aborted?: boolean;
+ error?: string;
+};
+type LifecycleEvent = {
+ stream?: string;
+ runId: string;
+ data?: LifecycleData;
+};
+
+let lifecycleHandler: ((evt: LifecycleEvent) => void) | undefined;
+const callGatewayMock = vi.fn(async (request: unknown) => {
+ const method = (request as { method?: string }).method;
+ if (method === "agent.wait") {
+ // Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised.
+ return { status: "pending" };
+ }
+ return {};
+});
+const onAgentEventMock = vi.fn((handler: typeof lifecycleHandler) => {
+ lifecycleHandler = handler;
+ return noop;
+});
+const loadConfigMock = vi.fn(() => ({
+ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
+}));
+const loadRegistryMock = vi.fn(() => new Map());
+const saveRegistryMock = vi.fn(() => {});
+const announceSpy = vi.fn(async () => true);
vi.mock("../gateway/call.js", () => ({
- callGateway: vi.fn(async (request: unknown) => {
- const method = (request as { method?: string }).method;
- if (method === "agent.wait") {
- // Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised.
- return { status: "pending" };
- }
- return {};
- }),
+ callGateway: callGatewayMock,
}));
vi.mock("../infra/agent-events.js", () => ({
- onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => {
- lifecycleHandler = handler;
- return noop;
- }),
+ onAgentEvent: onAgentEventMock,
}));
vi.mock("../config/config.js", () => ({
- loadConfig: vi.fn(() => ({
- agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
- })),
+ loadConfig: loadConfigMock,
}));
-const announceSpy = vi.fn(async () => true);
vi.mock("./subagent-announce.js", () => ({
runSubagentAnnounceFlow: announceSpy,
}));
@@ -50,8 +58,8 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
}));
vi.mock("./subagent-registry.store.js", () => ({
- loadSubagentRegistryFromDisk: vi.fn(() => new Map()),
- saveSubagentRegistryToDisk: vi.fn(() => {}),
+ loadSubagentRegistryFromDisk: loadRegistryMock,
+ saveSubagentRegistryToDisk: saveRegistryMock,
}));
describe("subagent registry lifecycle error grace", () => {
@@ -77,21 +85,41 @@ describe("subagent registry lifecycle error grace", () => {
await Promise.resolve();
};
- it("ignores transient lifecycle errors when run retries and then ends successfully", async () => {
+ function registerCompletionRun(runId: string, childSuffix: string, task: string) {
mod.registerSubagentRun({
- runId: "run-transient-error",
- childSessionKey: "agent:main:subagent:transient-error",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
- task: "transient error test",
+ runId,
+ childSessionKey: `agent:main:subagent:${childSuffix}`,
+ requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
+ requesterDisplayKey: MAIN_REQUESTER_DISPLAY_KEY,
+ task,
cleanup: "keep",
expectsCompletionMessage: true,
});
+ }
+ function emitLifecycleEvent(runId: string, data: LifecycleData) {
lifecycleHandler?.({
stream: "lifecycle",
- runId: "run-transient-error",
- data: { phase: "error", error: "rate limit", endedAt: 1_000 },
+ runId,
+ data,
+ });
+ }
+
+ function readFirstAnnounceOutcome() {
+ const announceCalls = announceSpy.mock.calls as unknown as Array>;
+ const first = (announceCalls[0]?.[0] ?? {}) as {
+ outcome?: { status?: string; error?: string };
+ };
+ return first.outcome;
+ }
+
+ it("ignores transient lifecycle errors when run retries and then ends successfully", async () => {
+ registerCompletionRun("run-transient-error", "transient-error", "transient error test");
+
+ emitLifecycleEvent("run-transient-error", {
+ phase: "error",
+ error: "rate limit",
+ endedAt: 1_000,
});
await flushAsync();
expect(announceSpy).not.toHaveBeenCalled();
@@ -99,46 +127,26 @@ describe("subagent registry lifecycle error grace", () => {
await vi.advanceTimersByTimeAsync(14_999);
expect(announceSpy).not.toHaveBeenCalled();
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-transient-error",
- data: { phase: "start", startedAt: 1_050 },
- });
+ emitLifecycleEvent("run-transient-error", { phase: "start", startedAt: 1_050 });
await flushAsync();
await vi.advanceTimersByTimeAsync(20_000);
expect(announceSpy).not.toHaveBeenCalled();
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-transient-error",
- data: { phase: "end", endedAt: 1_250 },
- });
+ emitLifecycleEvent("run-transient-error", { phase: "end", endedAt: 1_250 });
await flushAsync();
expect(announceSpy).toHaveBeenCalledTimes(1);
- const announceCalls = announceSpy.mock.calls as unknown as Array>;
- const first = (announceCalls[0]?.[0] ?? {}) as {
- outcome?: { status?: string; error?: string };
- };
- expect(first.outcome?.status).toBe("ok");
+ expect(readFirstAnnounceOutcome()?.status).toBe("ok");
});
it("announces error when lifecycle error remains terminal after grace window", async () => {
- mod.registerSubagentRun({
- runId: "run-terminal-error",
- childSessionKey: "agent:main:subagent:terminal-error",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
- task: "terminal error test",
- cleanup: "keep",
- expectsCompletionMessage: true,
- });
+ registerCompletionRun("run-terminal-error", "terminal-error", "terminal error test");
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-terminal-error",
- data: { phase: "error", error: "fatal failure", endedAt: 2_000 },
+ emitLifecycleEvent("run-terminal-error", {
+ phase: "error",
+ error: "fatal failure",
+ endedAt: 2_000,
});
await flushAsync();
expect(announceSpy).not.toHaveBeenCalled();
@@ -147,11 +155,7 @@ describe("subagent registry lifecycle error grace", () => {
await flushAsync();
expect(announceSpy).toHaveBeenCalledTimes(1);
- const announceCalls = announceSpy.mock.calls as unknown as Array>;
- const first = (announceCalls[0]?.[0] ?? {}) as {
- outcome?: { status?: string; error?: string };
- };
- expect(first.outcome?.status).toBe("error");
- expect(first.outcome?.error).toBe("fatal failure");
+ expect(readFirstAnnounceOutcome()?.status).toBe("error");
+ expect(readFirstAnnounceOutcome()?.error).toBe("fatal failure");
});
});
diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts
index 2f200535c4e..28933d58d4c 100644
--- a/src/agents/subagent-registry.steer-restart.test.ts
+++ b/src/agents/subagent-registry.steer-restart.test.ts
@@ -84,6 +84,8 @@ vi.mock("./subagent-registry.store.js", () => ({
describe("subagent registry steer restarts", () => {
let mod: typeof import("./subagent-registry.js");
type RegisterSubagentRunInput = Parameters[0];
+ const MAIN_REQUESTER_SESSION_KEY = "agent:main:main";
+ const MAIN_REQUESTER_DISPLAY_KEY = "main";
beforeAll(async () => {
mod = await import("./subagent-registry.js");
@@ -135,23 +137,65 @@ describe("subagent registry steer restarts", () => {
task: string,
options: Partial> = {},
): void => {
- mod.registerSubagentRun({
+ registerRun({
runId,
childSessionKey,
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
+ task,
+ expectsCompletionMessage: true,
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
- task,
- cleanup: "keep",
- expectsCompletionMessage: true,
...options,
});
};
+ const registerRun = (
+ params: {
+ runId: string;
+ childSessionKey: string;
+ task: string;
+ requesterSessionKey?: string;
+ requesterDisplayKey?: string;
+ } & Partial<
+ Pick
+ >,
+ ): void => {
+ mod.registerSubagentRun({
+ runId: params.runId,
+ childSessionKey: params.childSessionKey,
+ requesterSessionKey: params.requesterSessionKey ?? MAIN_REQUESTER_SESSION_KEY,
+ requesterDisplayKey: params.requesterDisplayKey ?? MAIN_REQUESTER_DISPLAY_KEY,
+ requesterOrigin: params.requesterOrigin,
+ task: params.task,
+ cleanup: "keep",
+ spawnMode: params.spawnMode,
+ expectsCompletionMessage: params.expectsCompletionMessage,
+ });
+ };
+
+ const listMainRuns = () => mod.listSubagentRunsForRequester(MAIN_REQUESTER_SESSION_KEY);
+
+ const emitLifecycleEnd = (
+ runId: string,
+ data: {
+ startedAt?: number;
+ endedAt?: number;
+ aborted?: boolean;
+ error?: string;
+ } = {},
+ ) => {
+ lifecycleHandler?.({
+ stream: "lifecycle",
+ runId,
+ data: {
+ phase: "end",
+ ...data,
+ },
+ });
+ };
+
afterEach(async () => {
announceSpy.mockClear();
announceSpy.mockResolvedValue(true);
@@ -161,26 +205,19 @@ describe("subagent registry steer restarts", () => {
});
it("suppresses announce for interrupted runs and only announces the replacement run", async () => {
- mod.registerSubagentRun({
+ registerRun({
runId: "run-old",
childSessionKey: "agent:main:subagent:steer",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
task: "initial task",
- cleanup: "keep",
});
- const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
+ const previous = listMainRuns()[0];
expect(previous?.runId).toBe("run-old");
const marked = mod.markSubagentRunForSteerRestart("run-old");
expect(marked).toBe(true);
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-old",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-old");
await flushAnnounce();
expect(announceSpy).not.toHaveBeenCalled();
@@ -193,15 +230,11 @@ describe("subagent registry steer restarts", () => {
});
expect(replaced).toBe(true);
- const runs = mod.listSubagentRunsForRequester("agent:main:main");
+ const runs = listMainRuns();
expect(runs).toHaveLength(1);
expect(runs[0].runId).toBe("run-new");
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-new",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-new");
await flushAnnounce();
expect(announceSpy).toHaveBeenCalledTimes(1);
@@ -228,11 +261,7 @@ describe("subagent registry steer restarts", () => {
"completion-mode task",
);
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-completion-delayed",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-completion-delayed");
await flushAnnounce();
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
@@ -249,7 +278,7 @@ describe("subagent registry steer restarts", () => {
}),
expect.objectContaining({
runId: "run-completion-delayed",
- requesterSessionKey: "agent:main:main",
+ requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
}),
);
});
@@ -265,11 +294,7 @@ describe("subagent registry steer restarts", () => {
{ spawnMode: "session" },
);
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-persistent-session",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-persistent-session");
await flushAnnounce();
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
@@ -278,7 +303,7 @@ describe("subagent registry steer restarts", () => {
await flushAnnounce();
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
- const run = mod.listSubagentRunsForRequester("agent:main:main")[0];
+ const run = listMainRuns()[0];
expect(run?.runId).toBe("run-persistent-session");
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
expect(run?.endedHookEmittedAt).toBeUndefined();
@@ -286,16 +311,13 @@ describe("subagent registry steer restarts", () => {
});
it("clears announce retry state when replacing after steer restart", () => {
- mod.registerSubagentRun({
+ registerRun({
runId: "run-retry-reset-old",
childSessionKey: "agent:main:subagent:retry-reset",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
task: "retry reset",
- cleanup: "keep",
});
- const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
+ const previous = listMainRuns()[0];
expect(previous?.runId).toBe("run-retry-reset-old");
if (previous) {
previous.announceRetryCount = 2;
@@ -309,7 +331,7 @@ describe("subagent registry steer restarts", () => {
});
expect(replaced).toBe(true);
- const runs = mod.listSubagentRunsForRequester("agent:main:main");
+ const runs = listMainRuns();
expect(runs).toHaveLength(1);
expect(runs[0].runId).toBe("run-retry-reset-new");
expect(runs[0].announceRetryCount).toBeUndefined();
@@ -317,16 +339,13 @@ describe("subagent registry steer restarts", () => {
});
it("clears terminal lifecycle state when replacing after steer restart", async () => {
- mod.registerSubagentRun({
+ registerRun({
runId: "run-terminal-state-old",
childSessionKey: "agent:main:subagent:terminal-state",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
task: "terminal state",
- cleanup: "keep",
});
- const previous = mod.listSubagentRunsForRequester("agent:main:main")[0];
+ const previous = listMainRuns()[0];
expect(previous?.runId).toBe("run-terminal-state-old");
if (previous) {
previous.endedHookEmittedAt = Date.now();
@@ -342,17 +361,13 @@ describe("subagent registry steer restarts", () => {
});
expect(replaced).toBe(true);
- const runs = mod.listSubagentRunsForRequester("agent:main:main");
+ const runs = listMainRuns();
expect(runs).toHaveLength(1);
expect(runs[0].runId).toBe("run-terminal-state-new");
expect(runs[0].endedHookEmittedAt).toBeUndefined();
expect(runs[0].endedReason).toBeUndefined();
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-terminal-state-new",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-terminal-state-new");
await flushAnnounce();
expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1);
@@ -367,22 +382,15 @@ describe("subagent registry steer restarts", () => {
});
it("restores announce for a finished run when steer replacement dispatch fails", async () => {
- mod.registerSubagentRun({
+ registerRun({
runId: "run-failed-restart",
childSessionKey: "agent:main:subagent:failed-restart",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
task: "initial task",
- cleanup: "keep",
});
expect(mod.markSubagentRunForSteerRestart("run-failed-restart")).toBe(true);
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-failed-restart",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-failed-restart");
await flushAnnounce();
expect(announceSpy).not.toHaveBeenCalled();
@@ -398,13 +406,10 @@ describe("subagent registry steer restarts", () => {
it("marks killed runs terminated and inactive", async () => {
const childSessionKey = "agent:main:subagent:killed";
- mod.registerSubagentRun({
+ registerRun({
runId: "run-killed",
childSessionKey,
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
task: "kill me",
- cleanup: "keep",
});
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(true);
@@ -415,7 +420,7 @@ describe("subagent registry steer restarts", () => {
expect(updated).toBe(1);
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(false);
- const run = mod.listSubagentRunsForRequester("agent:main:main")[0];
+ const run = listMainRuns()[0];
expect(run?.outcome).toEqual({ status: "error", error: "manual kill" });
expect(run?.cleanupHandled).toBe(true);
expect(typeof run?.cleanupCompletedAt).toBe("number");
@@ -434,7 +439,7 @@ describe("subagent registry steer restarts", () => {
{
runId: "run-killed",
childSessionKey,
- requesterSessionKey: "agent:main:main",
+ requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
},
);
});
@@ -450,35 +455,23 @@ describe("subagent registry steer restarts", () => {
return true;
});
- mod.registerSubagentRun({
+ registerRun({
runId: "run-parent",
childSessionKey: "agent:main:subagent:parent",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
task: "parent task",
- cleanup: "keep",
});
- mod.registerSubagentRun({
+ registerRun({
runId: "run-child",
childSessionKey: "agent:main:subagent:parent:subagent:child",
requesterSessionKey: "agent:main:subagent:parent",
requesterDisplayKey: "parent",
task: "child task",
- cleanup: "keep",
});
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-parent",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-parent");
await flushAnnounce();
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-child",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-child");
await flushAnnounce();
const childRunIds = announceSpy.mock.calls.map(
@@ -494,43 +487,33 @@ describe("subagent registry steer restarts", () => {
try {
announceSpy.mockResolvedValue(false);
- mod.registerSubagentRun({
- runId: "run-completion-retry",
- childSessionKey: "agent:main:subagent:completion",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
- task: "completion retry",
- cleanup: "keep",
- expectsCompletionMessage: true,
- });
+ registerCompletionModeRun(
+ "run-completion-retry",
+ "agent:main:subagent:completion",
+ "completion retry",
+ );
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-completion-retry",
- data: { phase: "end" },
- });
+ emitLifecycleEnd("run-completion-retry");
await vi.advanceTimersByTimeAsync(0);
expect(announceSpy).toHaveBeenCalledTimes(1);
- expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(1);
+ expect(listMainRuns()[0]?.announceRetryCount).toBe(1);
await vi.advanceTimersByTimeAsync(999);
expect(announceSpy).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(announceSpy).toHaveBeenCalledTimes(2);
- expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(2);
+ expect(listMainRuns()[0]?.announceRetryCount).toBe(2);
await vi.advanceTimersByTimeAsync(1_999);
expect(announceSpy).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(announceSpy).toHaveBeenCalledTimes(3);
- expect(mod.listSubagentRunsForRequester("agent:main:main")[0]?.announceRetryCount).toBe(3);
+ expect(listMainRuns()[0]?.announceRetryCount).toBe(3);
await vi.advanceTimersByTimeAsync(4_001);
expect(announceSpy).toHaveBeenCalledTimes(3);
- expect(
- mod.listSubagentRunsForRequester("agent:main:main")[0]?.cleanupCompletedAt,
- ).toBeTypeOf("number");
+ expect(listMainRuns()[0]?.cleanupCompletedAt).toBeTypeOf("number");
} finally {
vi.useRealTimers();
}
@@ -540,32 +523,22 @@ describe("subagent registry steer restarts", () => {
it("keeps completion cleanup pending while descendants are still active", async () => {
announceSpy.mockResolvedValue(false);
- mod.registerSubagentRun({
- runId: "run-parent-expiry",
- childSessionKey: "agent:main:subagent:parent-expiry",
- requesterSessionKey: "agent:main:main",
- requesterDisplayKey: "main",
- task: "parent completion expiry",
- cleanup: "keep",
- expectsCompletionMessage: true,
- });
- mod.registerSubagentRun({
+ registerCompletionModeRun(
+ "run-parent-expiry",
+ "agent:main:subagent:parent-expiry",
+ "parent completion expiry",
+ );
+ registerRun({
runId: "run-child-active",
childSessionKey: "agent:main:subagent:parent-expiry:subagent:child-active",
requesterSessionKey: "agent:main:subagent:parent-expiry",
requesterDisplayKey: "parent-expiry",
task: "child still running",
- cleanup: "keep",
});
- lifecycleHandler?.({
- stream: "lifecycle",
- runId: "run-parent-expiry",
- data: {
- phase: "end",
- startedAt: Date.now() - 7 * 60_000,
- endedAt: Date.now() - 6 * 60_000,
- },
+ emitLifecycleEnd("run-parent-expiry", {
+ startedAt: Date.now() - 7 * 60_000,
+ endedAt: Date.now() - 6 * 60_000,
});
await flushAnnounce();
@@ -576,7 +549,7 @@ describe("subagent registry steer restarts", () => {
});
expect(parentHookCall).toBeUndefined();
const parent = mod
- .listSubagentRunsForRequester("agent:main:main")
+ .listSubagentRunsForRequester(MAIN_REQUESTER_SESSION_KEY)
.find((entry) => entry.runId === "run-parent-expiry");
expect(parent?.cleanupCompletedAt).toBeUndefined();
expect(parent?.cleanupHandled).toBe(false);
diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts
index 86636dced4f..3f08e2c3ce4 100644
--- a/src/agents/tools/message-tool.test.ts
+++ b/src/agents/tools/message-tool.test.ts
@@ -40,6 +40,58 @@ function getActionEnum(properties: Record) {
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
}
+function createChannelPlugin(params: {
+ id: string;
+ label: string;
+ docsPath: string;
+ blurb: string;
+ actions: string[];
+ supportsButtons?: boolean;
+ messaging?: ChannelPlugin["messaging"];
+}): ChannelPlugin {
+ return {
+ id: params.id as ChannelPlugin["id"],
+ meta: {
+ id: params.id as ChannelPlugin["id"],
+ label: params.label,
+ selectionLabel: params.label,
+ docsPath: params.docsPath,
+ blurb: params.blurb,
+ },
+ capabilities: { chatTypes: ["direct", "group"], media: true },
+ config: {
+ listAccountIds: () => ["default"],
+ resolveAccount: () => ({}),
+ },
+ ...(params.messaging ? { messaging: params.messaging } : {}),
+ actions: {
+ listActions: () => params.actions as never,
+ ...(params.supportsButtons ? { supportsButtons: () => true } : {}),
+ },
+ };
+}
+
+async function executeSend(params: {
+ action: Record;
+ toolOptions?: Partial[0]>;
+}) {
+ const tool = createMessageTool({
+ config: {} as never,
+ ...params.toolOptions,
+ });
+ await tool.execute("1", {
+ action: "send",
+ ...params.action,
+ });
+ return mocks.runMessageAction.mock.calls[0]?.[0] as
+ | {
+ params?: Record;
+ sandboxRoot?: string;
+ requesterSenderId?: string;
+ }
+ | undefined;
+}
+
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mockSendResult();
@@ -62,141 +114,103 @@ describe("message tool agent routing", () => {
});
describe("message tool path passthrough", () => {
- it("does not convert path to media for send", async () => {
+ it.each([
+ { field: "path", value: "~/Downloads/voice.ogg" },
+ { field: "filePath", value: "./tmp/note.m4a" },
+ ])("does not convert $field to media for send", async ({ field, value }) => {
mockSendResult({ to: "telegram:123" });
- const tool = createMessageTool({
- config: {} as never,
+ const call = await executeSend({
+ action: {
+ target: "telegram:123",
+ [field]: value,
+ message: "",
+ },
});
- await tool.execute("1", {
- action: "send",
- target: "telegram:123",
- path: "~/Downloads/voice.ogg",
- message: "",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.params?.path).toBe("~/Downloads/voice.ogg");
- expect(call?.params?.media).toBeUndefined();
- });
-
- it("does not convert filePath to media for send", async () => {
- mockSendResult({ to: "telegram:123" });
-
- const tool = createMessageTool({
- config: {} as never,
- });
-
- await tool.execute("1", {
- action: "send",
- target: "telegram:123",
- filePath: "./tmp/note.m4a",
- message: "",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.params?.filePath).toBe("./tmp/note.m4a");
+ expect(call?.params?.[field]).toBe(value);
expect(call?.params?.media).toBeUndefined();
});
});
describe("message tool schema scoping", () => {
- const telegramPlugin: ChannelPlugin = {
+ const telegramPlugin = createChannelPlugin({
id: "telegram",
- meta: {
- id: "telegram",
- label: "Telegram",
- selectionLabel: "Telegram",
- docsPath: "/channels/telegram",
- blurb: "Telegram test plugin.",
- },
- capabilities: { chatTypes: ["direct", "group"], media: true },
- config: {
- listAccountIds: () => ["default"],
- resolveAccount: () => ({}),
- },
- actions: {
- listActions: () => ["send", "react"] as const,
- supportsButtons: () => true,
- },
- };
+ label: "Telegram",
+ docsPath: "/channels/telegram",
+ blurb: "Telegram test plugin.",
+ actions: ["send", "react"],
+ supportsButtons: true,
+ });
- const discordPlugin: ChannelPlugin = {
+ const discordPlugin = createChannelPlugin({
id: "discord",
- meta: {
- id: "discord",
- label: "Discord",
- selectionLabel: "Discord",
- docsPath: "/channels/discord",
- blurb: "Discord test plugin.",
- },
- capabilities: { chatTypes: ["direct", "group"], media: true },
- config: {
- listAccountIds: () => ["default"],
- resolveAccount: () => ({}),
- },
- actions: {
- listActions: () => ["send", "poll"] as const,
- },
- };
+ label: "Discord",
+ docsPath: "/channels/discord",
+ blurb: "Discord test plugin.",
+ actions: ["send", "poll"],
+ });
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
- it("hides discord components when scoped to telegram", () => {
- setActivePluginRegistry(
- createTestRegistry([
- { pluginId: "telegram", source: "test", plugin: telegramPlugin },
- { pluginId: "discord", source: "test", plugin: discordPlugin },
- ]),
- );
+ it.each([
+ {
+ provider: "telegram",
+ expectComponents: false,
+ expectButtons: true,
+ expectButtonStyle: true,
+ expectedActions: ["send", "react", "poll"],
+ },
+ {
+ provider: "discord",
+ expectComponents: true,
+ expectButtons: false,
+ expectButtonStyle: false,
+ expectedActions: ["send", "poll", "react"],
+ },
+ ])(
+ "scopes schema fields for $provider",
+ ({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => {
+ setActivePluginRegistry(
+ createTestRegistry([
+ { pluginId: "telegram", source: "test", plugin: telegramPlugin },
+ { pluginId: "discord", source: "test", plugin: discordPlugin },
+ ]),
+ );
- const tool = createMessageTool({
- config: {} as never,
- currentChannelProvider: "telegram",
- });
- const properties = getToolProperties(tool);
- const actionEnum = getActionEnum(properties);
+ const tool = createMessageTool({
+ config: {} as never,
+ currentChannelProvider: provider,
+ });
+ const properties = getToolProperties(tool);
+ const actionEnum = getActionEnum(properties);
- expect(properties.components).toBeUndefined();
- expect(properties.buttons).toBeDefined();
- const buttonItemProps =
- (
- properties.buttons as {
- items?: { items?: { properties?: Record } };
- }
- )?.items?.items?.properties ?? {};
- expect(buttonItemProps.style).toBeDefined();
- expect(actionEnum).toContain("send");
- expect(actionEnum).toContain("react");
- // Other channels' actions are included so isolated/cron agents can use them
- expect(actionEnum).toContain("poll");
- });
-
- it("shows discord components when scoped to discord", () => {
- setActivePluginRegistry(
- createTestRegistry([
- { pluginId: "telegram", source: "test", plugin: telegramPlugin },
- { pluginId: "discord", source: "test", plugin: discordPlugin },
- ]),
- );
-
- const tool = createMessageTool({
- config: {} as never,
- currentChannelProvider: "discord",
- });
- const properties = getToolProperties(tool);
- const actionEnum = getActionEnum(properties);
-
- expect(properties.components).toBeDefined();
- expect(properties.buttons).toBeUndefined();
- expect(actionEnum).toContain("send");
- expect(actionEnum).toContain("poll");
- // Other channels' actions are included so isolated/cron agents can use them
- expect(actionEnum).toContain("react");
- });
+ if (expectComponents) {
+ expect(properties.components).toBeDefined();
+ } else {
+ expect(properties.components).toBeUndefined();
+ }
+ if (expectButtons) {
+ expect(properties.buttons).toBeDefined();
+ } else {
+ expect(properties.buttons).toBeUndefined();
+ }
+ if (expectButtonStyle) {
+ const buttonItemProps =
+ (
+ properties.buttons as {
+ items?: { items?: { properties?: Record } };
+ }
+ )?.items?.items?.properties ?? {};
+ expect(buttonItemProps.style).toBeDefined();
+ }
+ for (const action of expectedActions) {
+ expect(actionEnum).toContain(action);
+ }
+ },
+ );
});
describe("message tool description", () => {
@@ -204,20 +218,12 @@ describe("message tool description", () => {
setActivePluginRegistry(createTestRegistry([]));
});
- const bluebubblesPlugin: ChannelPlugin = {
+ const bluebubblesPlugin = createChannelPlugin({
id: "bluebubbles",
- meta: {
- id: "bluebubbles",
- label: "BlueBubbles",
- selectionLabel: "BlueBubbles",
- docsPath: "/channels/bluebubbles",
- blurb: "BlueBubbles test plugin.",
- },
- capabilities: { chatTypes: ["direct", "group"], media: true },
- config: {
- listAccountIds: () => ["default"],
- resolveAccount: () => ({}),
- },
+ label: "BlueBubbles",
+ docsPath: "/channels/bluebubbles",
+ blurb: "BlueBubbles test plugin.",
+ actions: ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"],
messaging: {
normalizeTarget: (raw) => {
const trimmed = raw.trim().replace(/^bluebubbles:/i, "");
@@ -233,11 +239,7 @@ describe("message tool description", () => {
return trimmed;
},
},
- actions: {
- listActions: () =>
- ["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"] as const,
- },
- };
+ });
it("hides BlueBubbles group actions for DM targets", () => {
setActivePluginRegistry(
@@ -257,43 +259,21 @@ describe("message tool description", () => {
});
it("includes other configured channels when currentChannel is set", () => {
- const signalPlugin: ChannelPlugin = {
+ const signalPlugin = createChannelPlugin({
id: "signal",
- meta: {
- id: "signal",
- label: "Signal",
- selectionLabel: "Signal",
- docsPath: "/channels/signal",
- blurb: "Signal test plugin.",
- },
- capabilities: { chatTypes: ["direct", "group"], media: true },
- config: {
- listAccountIds: () => ["default"],
- resolveAccount: () => ({}),
- },
- actions: {
- listActions: () => ["send", "react"] as const,
- },
- };
+ label: "Signal",
+ docsPath: "/channels/signal",
+ blurb: "Signal test plugin.",
+ actions: ["send", "react"],
+ });
- const telegramPluginFull: ChannelPlugin = {
+ const telegramPluginFull = createChannelPlugin({
id: "telegram",
- meta: {
- id: "telegram",
- label: "Telegram",
- selectionLabel: "Telegram",
- docsPath: "/channels/telegram",
- blurb: "Telegram test plugin.",
- },
- capabilities: { chatTypes: ["direct", "group"], media: true },
- config: {
- listAccountIds: () => ["default"],
- resolveAccount: () => ({}),
- },
- actions: {
- listActions: () => ["send", "react", "delete", "edit", "topic-create"] as const,
- },
- };
+ label: "Telegram",
+ docsPath: "/channels/telegram",
+ blurb: "Telegram test plugin.",
+ actions: ["send", "react", "delete", "edit", "topic-create"],
+ });
setActivePluginRegistry(
createTestRegistry([
@@ -330,103 +310,80 @@ describe("message tool description", () => {
});
describe("message tool reasoning tag sanitization", () => {
- it("strips tags from text field before sending", async () => {
- mockSendResult({ channel: "signal", to: "signal:+15551234567" });
-
- const tool = createMessageTool({ config: {} as never });
-
- await tool.execute("1", {
- action: "send",
+ it.each([
+ {
+ field: "text",
+ input: "internal reasoningHello!",
+ expected: "Hello!",
target: "signal:+15551234567",
- text: "internal reasoningHello!",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.params?.text).toBe("Hello!");
- });
-
- it("strips tags from content field before sending", async () => {
- mockSendResult({ channel: "discord", to: "discord:123" });
-
- const tool = createMessageTool({ config: {} as never });
-
- await tool.execute("1", {
- action: "send",
+ channel: "signal",
+ },
+ {
+ field: "content",
+ input: "reasoning hereReply text",
+ expected: "Reply text",
target: "discord:123",
- content: "reasoning hereReply text",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.params?.content).toBe("Reply text");
- });
-
- it("passes through text without reasoning tags unchanged", async () => {
- mockSendResult({ channel: "signal", to: "signal:+15551234567" });
-
- const tool = createMessageTool({ config: {} as never });
-
- await tool.execute("1", {
- action: "send",
+ channel: "discord",
+ },
+ {
+ field: "text",
+ input: "Normal message without any tags",
+ expected: "Normal message without any tags",
target: "signal:+15551234567",
- text: "Normal message without any tags",
- });
+ channel: "signal",
+ },
+ ])(
+ "sanitizes reasoning tags in $field before sending",
+ async ({ channel, target, field, input, expected }) => {
+ mockSendResult({ channel, to: target });
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.params?.text).toBe("Normal message without any tags");
- });
+ const call = await executeSend({
+ action: {
+ target,
+ [field]: input,
+ },
+ });
+ expect(call?.params?.[field]).toBe(expected);
+ },
+ );
});
describe("message tool sandbox passthrough", () => {
- it("forwards sandboxRoot to runMessageAction", async () => {
+ it.each([
+ {
+ name: "forwards sandboxRoot to runMessageAction",
+ toolOptions: { sandboxRoot: "/tmp/sandbox" },
+ expected: "/tmp/sandbox",
+ },
+ {
+ name: "omits sandboxRoot when not configured",
+ toolOptions: {},
+ expected: undefined,
+ },
+ ])("$name", async ({ toolOptions, expected }) => {
mockSendResult({ to: "telegram:123" });
- const tool = createMessageTool({
- config: {} as never,
- sandboxRoot: "/tmp/sandbox",
+ const call = await executeSend({
+ toolOptions,
+ action: {
+ target: "telegram:123",
+ message: "",
+ },
});
-
- await tool.execute("1", {
- action: "send",
- target: "telegram:123",
- message: "",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.sandboxRoot).toBe("/tmp/sandbox");
- });
-
- it("omits sandboxRoot when not configured", async () => {
- mockSendResult({ to: "telegram:123" });
-
- const tool = createMessageTool({
- config: {} as never,
- });
-
- await tool.execute("1", {
- action: "send",
- target: "telegram:123",
- message: "",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
- expect(call?.sandboxRoot).toBeUndefined();
+ expect(call?.sandboxRoot).toBe(expected);
});
it("forwards trusted requesterSenderId to runMessageAction", async () => {
mockSendResult({ to: "discord:123" });
- const tool = createMessageTool({
- config: {} as never,
- requesterSenderId: "1234567890",
+ const call = await executeSend({
+ toolOptions: { requesterSenderId: "1234567890" },
+ action: {
+ target: "discord:123",
+ message: "hi",
+ },
});
- await tool.execute("1", {
- action: "send",
- target: "discord:123",
- message: "hi",
- });
-
- const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.requesterSenderId).toBe("1234567890");
});
});
diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts
index 0d381a3e496..aa831027f68 100644
--- a/src/agents/tools/sessions.test.ts
+++ b/src/agents/tools/sessions.test.ts
@@ -35,6 +35,10 @@ import { createSessionsSendTool } from "./sessions-send-tool.js";
let resolveAnnounceTarget: (typeof import("./sessions-announce-target.js"))["resolveAnnounceTarget"];
let setActivePluginRegistry: (typeof import("../../plugins/runtime.js"))["setActivePluginRegistry"];
+const MAIN_AGENT_SESSION_KEY = "agent:main:main";
+const MAIN_AGENT_CHANNEL = "whatsapp";
+
+type SessionsListResult = Awaited["execute"]>>;
const installRegistry = async () => {
setActivePluginRegistry(
@@ -82,6 +86,52 @@ const installRegistry = async () => {
);
};
+function createMainSessionsListTool() {
+ return createSessionsListTool({ agentSessionKey: MAIN_AGENT_SESSION_KEY });
+}
+
+async function executeMainSessionsList() {
+ return createMainSessionsListTool().execute("call1", {});
+}
+
+function createMainSessionsSendTool() {
+ return createSessionsSendTool({
+ agentSessionKey: MAIN_AGENT_SESSION_KEY,
+ agentChannel: MAIN_AGENT_CHANNEL,
+ });
+}
+
+function getFirstListedSession(result: SessionsListResult) {
+ const details = result.details as
+ | { sessions?: Array<{ key?: string; transcriptPath?: string }> }
+ | undefined;
+ return details?.sessions?.[0];
+}
+
+function expectWorkerTranscriptPath(
+ result: SessionsListResult,
+ params: { containsPath: string; sessionId: string },
+) {
+ const session = getFirstListedSession(result);
+ expect(session).toMatchObject({ key: "agent:worker:main" });
+ const transcriptPath = String(session?.transcriptPath ?? "");
+ expect(path.normalize(transcriptPath)).toContain(path.normalize(params.containsPath));
+ expect(transcriptPath).toMatch(new RegExp(`${params.sessionId}\\.jsonl$`));
+}
+
+async function withStubbedStateDir(
+ name: string,
+ run: (stateDir: string) => Promise,
+): Promise {
+ const stateDir = path.join(os.tmpdir(), name);
+ vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
+ try {
+ return await run(stateDir);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+}
+
describe("sanitizeTextContent", () => {
it("strips minimax tool call XML and downgraded markers", () => {
const input =
@@ -209,11 +259,11 @@ describe("sessions_list gating", () => {
});
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
- const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
+ const tool = createMainSessionsListTool();
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({
count: 1,
- sessions: [{ key: "agent:main:main" }],
+ sessions: [{ key: MAIN_AGENT_SESSION_KEY }],
});
});
});
@@ -231,10 +281,7 @@ describe("sessions_list transcriptPath resolution", () => {
});
it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => {
- const stateDir = path.join(os.tmpdir(), "openclaw-state-relative");
- vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
-
- try {
+ await withStubbedStateDir("openclaw-state-relative", async () => {
callGatewayMock.mockResolvedValueOnce({
path: "agents/main/sessions/sessions.json",
sessions: [
@@ -246,27 +293,16 @@ describe("sessions_list transcriptPath resolution", () => {
],
});
- const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
- const result = await tool.execute("call1", {});
-
- const details = result.details as
- | { sessions?: Array<{ key?: string; transcriptPath?: string }> }
- | undefined;
- const session = details?.sessions?.[0];
- expect(session).toMatchObject({ key: "agent:worker:main" });
- const transcriptPath = String(session?.transcriptPath ?? "");
- expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
- expect(transcriptPath).toMatch(/sess-worker\.jsonl$/);
- } finally {
- vi.unstubAllEnvs();
- }
+ const result = await executeMainSessionsList();
+ expectWorkerTranscriptPath(result, {
+ containsPath: path.join("agents", "worker", "sessions"),
+ sessionId: "sess-worker",
+ });
+ });
});
it("resolves transcriptPath even when sessions.list does not return a store path", async () => {
- const stateDir = path.join(os.tmpdir(), "openclaw-state-no-path");
- vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
-
- try {
+ await withStubbedStateDir("openclaw-state-no-path", async () => {
callGatewayMock.mockResolvedValueOnce({
sessions: [
{
@@ -277,27 +313,16 @@ describe("sessions_list transcriptPath resolution", () => {
],
});
- const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
- const result = await tool.execute("call1", {});
-
- const details = result.details as
- | { sessions?: Array<{ key?: string; transcriptPath?: string }> }
- | undefined;
- const session = details?.sessions?.[0];
- expect(session).toMatchObject({ key: "agent:worker:main" });
- const transcriptPath = String(session?.transcriptPath ?? "");
- expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
- expect(transcriptPath).toMatch(/sess-worker-no-path\.jsonl$/);
- } finally {
- vi.unstubAllEnvs();
- }
+ const result = await executeMainSessionsList();
+ expectWorkerTranscriptPath(result, {
+ containsPath: path.join("agents", "worker", "sessions"),
+ sessionId: "sess-worker-no-path",
+ });
+ });
});
it("falls back to agent defaults when gateway path is non-string", async () => {
- const stateDir = path.join(os.tmpdir(), "openclaw-state-non-string-path");
- vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
-
- try {
+ await withStubbedStateDir("openclaw-state-non-string-path", async () => {
callGatewayMock.mockResolvedValueOnce({
path: { raw: "agents/main/sessions/sessions.json" },
sessions: [
@@ -309,27 +334,16 @@ describe("sessions_list transcriptPath resolution", () => {
],
});
- const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
- const result = await tool.execute("call1", {});
-
- const details = result.details as
- | { sessions?: Array<{ key?: string; transcriptPath?: string }> }
- | undefined;
- const session = details?.sessions?.[0];
- expect(session).toMatchObject({ key: "agent:worker:main" });
- const transcriptPath = String(session?.transcriptPath ?? "");
- expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions"));
- expect(transcriptPath).toMatch(/sess-worker-shape\.jsonl$/);
- } finally {
- vi.unstubAllEnvs();
- }
+ const result = await executeMainSessionsList();
+ expectWorkerTranscriptPath(result, {
+ containsPath: path.join("agents", "worker", "sessions"),
+ sessionId: "sess-worker-shape",
+ });
+ });
});
it("falls back to agent defaults when gateway path is '(multiple)'", async () => {
- const stateDir = path.join(os.tmpdir(), "openclaw-state-multiple");
- vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
-
- try {
+ await withStubbedStateDir("openclaw-state-multiple", async (stateDir) => {
callGatewayMock.mockResolvedValueOnce({
path: "(multiple)",
sessions: [
@@ -341,22 +355,12 @@ describe("sessions_list transcriptPath resolution", () => {
],
});
- const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
- const result = await tool.execute("call1", {});
-
- const details = result.details as
- | { sessions?: Array<{ key?: string; transcriptPath?: string }> }
- | undefined;
- const session = details?.sessions?.[0];
- expect(session).toMatchObject({ key: "agent:worker:main" });
- const transcriptPath = String(session?.transcriptPath ?? "");
- expect(path.normalize(transcriptPath)).toContain(
- path.join(stateDir, "agents", "worker", "sessions"),
- );
- expect(transcriptPath).toMatch(/sess-worker-multiple\.jsonl$/);
- } finally {
- vi.unstubAllEnvs();
- }
+ const result = await executeMainSessionsList();
+ expectWorkerTranscriptPath(result, {
+ containsPath: path.join(stateDir, "agents", "worker", "sessions"),
+ sessionId: "sess-worker-multiple",
+ });
+ });
});
it("resolves absolute {agentId} template paths per session agent", async () => {
@@ -373,18 +377,12 @@ describe("sessions_list transcriptPath resolution", () => {
],
});
- const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
- const result = await tool.execute("call1", {});
-
- const details = result.details as
- | { sessions?: Array<{ key?: string; transcriptPath?: string }> }
- | undefined;
- const session = details?.sessions?.[0];
- expect(session).toMatchObject({ key: "agent:worker:main" });
- const transcriptPath = String(session?.transcriptPath ?? "");
+ const result = await executeMainSessionsList();
const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker"));
- expect(path.normalize(transcriptPath)).toContain(path.normalize(expectedSessionsDir));
- expect(transcriptPath).toMatch(/sess-worker-template\.jsonl$/);
+ expectWorkerTranscriptPath(result, {
+ containsPath: expectedSessionsDir,
+ sessionId: "sess-worker-template",
+ });
});
});
@@ -394,10 +392,7 @@ describe("sessions_send gating", () => {
});
it("returns an error when neither sessionKey nor label is provided", async () => {
- const tool = createSessionsSendTool({
- agentSessionKey: "agent:main:main",
- agentChannel: "whatsapp",
- });
+ const tool = createMainSessionsSendTool();
const result = await tool.execute("call-missing-target", {
message: "hi",
@@ -413,10 +408,7 @@ describe("sessions_send gating", () => {
it("returns an error when label resolution fails", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("No session found with label: nope"));
- const tool = createSessionsSendTool({
- agentSessionKey: "agent:main:main",
- agentChannel: "whatsapp",
- });
+ const tool = createMainSessionsSendTool();
const result = await tool.execute("call-missing-label", {
label: "nope",
@@ -435,10 +427,7 @@ describe("sessions_send gating", () => {
});
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
- const tool = createSessionsSendTool({
- agentSessionKey: "agent:main:main",
- agentChannel: "whatsapp",
- });
+ const tool = createMainSessionsSendTool();
const result = await tool.execute("call1", {
sessionKey: "agent:other:main",
diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts
index ac236e3c02b..14302629a1c 100644
--- a/src/agents/workspace.test.ts
+++ b/src/agents/workspace.test.ts
@@ -44,18 +44,41 @@ async function readOnboardingState(dir: string): Promise<{
};
}
+async function expectBootstrapSeeded(dir: string) {
+ await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
+ const state = await readOnboardingState(dir);
+ expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
+}
+
+async function expectCompletedWithoutBootstrap(dir: string) {
+ await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
+ await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ const state = await readOnboardingState(dir);
+ expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
+}
+
+function expectSubagentAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) {
+ const names = files.map((file) => file.name);
+ expect(names).toContain("AGENTS.md");
+ expect(names).toContain("TOOLS.md");
+ expect(names).toContain("SOUL.md");
+ expect(names).toContain("IDENTITY.md");
+ expect(names).toContain("USER.md");
+ expect(names).not.toContain("HEARTBEAT.md");
+ expect(names).not.toContain("BOOTSTRAP.md");
+ expect(names).not.toContain("MEMORY.md");
+}
+
describe("ensureAgentWorkspace", () => {
it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
- await expect(
- fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
- ).resolves.toBeUndefined();
- const state = await readOnboardingState(tempDir);
- expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
- expect(state.onboardingCompletedAt).toBeUndefined();
+ await expectBootstrapSeeded(tempDir);
+ expect((await readOnboardingState(tempDir)).onboardingCompletedAt).toBeUndefined();
});
it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => {
@@ -64,11 +87,7 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
- await expect(
- fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
- ).resolves.toBeUndefined();
- const state = await readOnboardingState(tempDir);
- expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
+ await expectBootstrapSeeded(tempDir);
});
it("does not recreate BOOTSTRAP.md after completion, even when a core file is recreated", async () => {
@@ -129,12 +148,7 @@ describe("ensureAgentWorkspace", () => {
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
- await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
- await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
- code: "ENOENT",
- });
- const state = await readOnboardingState(tempDir);
- expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
+ await expectCompletedWithoutBootstrap(tempDir);
});
});
@@ -233,27 +247,11 @@ describe("filterBootstrapFilesForSession", () => {
it("filters to allowlist for subagent sessions", () => {
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1");
- const names = result.map((f) => f.name);
- expect(names).toContain("AGENTS.md");
- expect(names).toContain("TOOLS.md");
- expect(names).toContain("SOUL.md");
- expect(names).toContain("IDENTITY.md");
- expect(names).toContain("USER.md");
- expect(names).not.toContain("HEARTBEAT.md");
- expect(names).not.toContain("BOOTSTRAP.md");
- expect(names).not.toContain("MEMORY.md");
+ expectSubagentAllowedBootstrapNames(result);
});
it("filters to allowlist for cron sessions", () => {
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check");
- const names = result.map((f) => f.name);
- expect(names).toContain("AGENTS.md");
- expect(names).toContain("TOOLS.md");
- expect(names).toContain("SOUL.md");
- expect(names).toContain("IDENTITY.md");
- expect(names).toContain("USER.md");
- expect(names).not.toContain("HEARTBEAT.md");
- expect(names).not.toContain("BOOTSTRAP.md");
- expect(names).not.toContain("MEMORY.md");
+ expectSubagentAllowedBootstrapNames(result);
});
});
diff --git a/src/auto-reply/reply/abort.test.ts b/src/auto-reply/reply/abort.test.ts
index 9041380030d..dab520e6b24 100644
--- a/src/auto-reply/reply/abort.test.ts
+++ b/src/auto-reply/reply/abort.test.ts
@@ -124,6 +124,43 @@ describe("abort detection", () => {
});
}
+ function enqueueQueuedFollowupRun(params: {
+ root: string;
+ cfg: OpenClawConfig;
+ sessionId: string;
+ sessionKey: string;
+ }) {
+ const followupRun: FollowupRun = {
+ prompt: "queued",
+ enqueuedAt: Date.now(),
+ run: {
+ agentId: "main",
+ agentDir: path.join(params.root, "agent"),
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ messageProvider: "telegram",
+ agentAccountId: "acct",
+ sessionFile: path.join(params.root, "session.jsonl"),
+ workspaceDir: path.join(params.root, "workspace"),
+ config: params.cfg,
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ timeoutMs: 1000,
+ blockReplyBreak: "text_end",
+ },
+ };
+ enqueueFollowupRun(
+ params.sessionKey,
+ followupRun,
+ { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
+ "none",
+ );
+ }
+
+ function expectSessionLaneCleared(sessionKey: string) {
+ expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
+ }
+
afterEach(() => {
resetAbortMemoryForTest();
acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" });
@@ -338,31 +375,7 @@ describe("abort detection", () => {
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: { [sessionKey]: sessionId },
});
- const followupRun: FollowupRun = {
- prompt: "queued",
- enqueuedAt: Date.now(),
- run: {
- agentId: "main",
- agentDir: path.join(root, "agent"),
- sessionId,
- sessionKey,
- messageProvider: "telegram",
- agentAccountId: "acct",
- sessionFile: path.join(root, "session.jsonl"),
- workspaceDir: path.join(root, "workspace"),
- config: cfg,
- provider: "anthropic",
- model: "claude-opus-4-5",
- timeoutMs: 1000,
- blockReplyBreak: "text_end",
- },
- };
- enqueueFollowupRun(
- sessionKey,
- followupRun,
- { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
- "none",
- );
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
expect(getFollowupQueueDepth(sessionKey)).toBe(1);
const result = await runStopCommand({
@@ -374,7 +387,7 @@ describe("abort detection", () => {
expect(result.handled).toBe(true);
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
- expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
+ expectSessionLaneCleared(sessionKey);
});
it("plain-language stop on ACP-bound session triggers ACP cancel", async () => {
@@ -411,31 +424,7 @@ describe("abort detection", () => {
const { root, cfg } = await createAbortConfig({
sessionIdsByKey: { [sessionKey]: sessionId },
});
- const followupRun: FollowupRun = {
- prompt: "queued",
- enqueuedAt: Date.now(),
- run: {
- agentId: "main",
- agentDir: path.join(root, "agent"),
- sessionId,
- sessionKey,
- messageProvider: "telegram",
- agentAccountId: "acct",
- sessionFile: path.join(root, "session.jsonl"),
- workspaceDir: path.join(root, "workspace"),
- config: cfg,
- provider: "anthropic",
- model: "claude-opus-4-5",
- timeoutMs: 1000,
- blockReplyBreak: "text_end",
- },
- };
- enqueueFollowupRun(
- sessionKey,
- followupRun,
- { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
- "none",
- );
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
acpManagerMocks.resolveSession.mockReturnValue({
kind: "ready",
sessionKey,
@@ -453,7 +442,7 @@ describe("abort detection", () => {
expect(result.handled).toBe(true);
expect(getFollowupQueueDepth(sessionKey)).toBe(0);
- expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
+ expectSessionLaneCleared(sessionKey);
});
it("persists abort cutoff metadata on /stop when command and target session match", async () => {
@@ -546,7 +535,7 @@ describe("abort detection", () => {
});
expect(result.stoppedSubagents).toBe(1);
- expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${childKey}`);
+ expectSessionLaneCleared(childKey);
});
it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => {
@@ -601,8 +590,8 @@ describe("abort detection", () => {
// Should stop both depth-1 and depth-2 agents (cascade)
expect(result.stoppedSubagents).toBe(2);
- expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth1Key}`);
- expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`);
+ expectSessionLaneCleared(depth1Key);
+ expectSessionLaneCleared(depth2Key);
});
it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
@@ -660,7 +649,7 @@ describe("abort detection", () => {
// Should skip killing the ended depth-1 run itself, but still kill depth-2.
expect(result.stoppedSubagents).toBe(1);
- expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${depth2Key}`);
+ expectSessionLaneCleared(depth2Key);
expect(subagentRegistryMocks.markSubagentRunTerminated).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-2", childSessionKey: depth2Key }),
);
diff --git a/src/auto-reply/reply/acp-projector.test.ts b/src/auto-reply/reply/acp-projector.test.ts
index 7432f3c7a50..57882b3b755 100644
--- a/src/auto-reply/reply/acp-projector.test.ts
+++ b/src/auto-reply/reply/acp-projector.test.ts
@@ -3,17 +3,39 @@ import { prefixSystemMessage } from "../../infra/system-message.js";
import { createAcpReplyProjector } from "./acp-projector.js";
import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
+type Delivery = { kind: string; text?: string };
+
+function createProjectorHarness(cfgOverrides?: Parameters[0]) {
+ const deliveries: Delivery[] = [];
+ const projector = createAcpReplyProjector({
+ cfg: createCfg(cfgOverrides),
+ shouldSendToolSummaries: true,
+ deliver: async (kind, payload) => {
+ deliveries.push({ kind, text: payload.text });
+ return true;
+ },
+ });
+ return { deliveries, projector };
+}
+
+function blockDeliveries(deliveries: Delivery[]) {
+ return deliveries.filter((entry) => entry.kind === "block");
+}
+
+function combinedBlockText(deliveries: Delivery[]) {
+ return blockDeliveries(deliveries)
+ .map((entry) => entry.text ?? "")
+ .join("");
+}
+
+function expectToolCallSummary(delivery: Delivery | undefined) {
+ expect(delivery?.kind).toBe("tool");
+ expect(delivery?.text).toContain("Tool Call");
+}
+
describe("createAcpReplyProjector", () => {
it("coalesces text deltas into bounded block chunks", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg(),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
- },
- });
+ const { deliveries, projector } = createProjectorHarness();
await projector.onEvent({
type: "text_delta",
@@ -29,22 +51,14 @@ describe("createAcpReplyProjector", () => {
});
it("does not suppress identical short text across terminal turn boundaries", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- coalesceIdleMs: 0,
- maxChunkChars: 64,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ coalesceIdleMs: 0,
+ maxChunkChars: 64,
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -53,7 +67,7 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
await projector.onEvent({ type: "done", stopReason: "end_turn" });
- expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
+ expect(blockDeliveries(deliveries)).toEqual([
{ kind: "block", text: "A" },
{ kind: "block", text: "A" },
]);
@@ -62,22 +76,14 @@ describe("createAcpReplyProjector", () => {
it("flushes staggered live text deltas after idle gaps", async () => {
vi.useFakeTimers();
try {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- coalesceIdleMs: 50,
- maxChunkChars: 64,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ coalesceIdleMs: 50,
+ maxChunkChars: 64,
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -93,7 +99,7 @@ describe("createAcpReplyProjector", () => {
await vi.advanceTimersByTimeAsync(760);
await projector.flush(false);
- expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
+ expect(blockDeliveries(deliveries)).toEqual([
{ kind: "block", text: "A" },
{ kind: "block", text: "B" },
{ kind: "block", text: "C" },
@@ -104,22 +110,14 @@ describe("createAcpReplyProjector", () => {
});
it("splits oversized live text by maxChunkChars", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- coalesceIdleMs: 0,
- maxChunkChars: 50,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ coalesceIdleMs: 0,
+ maxChunkChars: 50,
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -127,7 +125,7 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
await projector.flush(true);
- expect(deliveries.filter((entry) => entry.kind === "block")).toEqual([
+ expect(blockDeliveries(deliveries)).toEqual([
{ kind: "block", text: "a".repeat(50) },
{ kind: "block", text: "b".repeat(50) },
{ kind: "block", text: "c".repeat(20) },
@@ -137,22 +135,14 @@ describe("createAcpReplyProjector", () => {
it("does not flush short live fragments mid-phrase on idle", async () => {
vi.useFakeTimers();
try {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- coalesceIdleMs: 100,
- maxChunkChars: 256,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ coalesceIdleMs: 100,
+ maxChunkChars: 256,
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -184,26 +174,18 @@ describe("createAcpReplyProjector", () => {
});
it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 512,
- deliveryMode: "final_only",
- tagVisibility: {
- available_commands_update: true,
- tool_call: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 512,
+ deliveryMode: "final_only",
+ tagVisibility: {
+ available_commands_update: true,
+ tool_call: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -238,32 +220,23 @@ describe("createAcpReplyProjector", () => {
kind: "tool",
text: prefixSystemMessage("available commands updated (7)"),
});
- expect(deliveries[1]?.kind).toBe("tool");
- expect(deliveries[1]?.text).toContain("Tool Call");
+ expectToolCallSummary(deliveries[1]);
expect(deliveries[2]).toEqual({ kind: "block", text: "What now?" });
});
it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 512,
- deliveryMode: "final_only",
- tagVisibility: {
- available_commands_update: true,
- tool_call: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 512,
+ deliveryMode: "final_only",
+ tagVisibility: {
+ available_commands_update: true,
+ tool_call: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -288,20 +261,11 @@ describe("createAcpReplyProjector", () => {
kind: "tool",
text: prefixSystemMessage("available commands updated (7)"),
});
- expect(deliveries[1]?.kind).toBe("tool");
- expect(deliveries[1]?.text).toContain("Tool Call");
+ expectToolCallSummary(deliveries[1]);
});
it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => {
- const hidden: Array<{ kind: string; text?: string }> = [];
- const hiddenProjector = createAcpReplyProjector({
- cfg: createCfg(),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- hidden.push({ kind, text: payload.text });
- return true;
- },
- });
+ const { deliveries: hidden, projector: hiddenProjector } = createProjectorHarness();
await hiddenProjector.onEvent({
type: "status",
text: "usage updated: 10/100",
@@ -311,25 +275,17 @@ describe("createAcpReplyProjector", () => {
});
expect(hidden).toEqual([]);
- const shown: Array<{ kind: string; text?: string }> = [];
- const shownProjector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 64,
- deliveryMode: "live",
- tagVisibility: {
- usage_update: true,
- },
+ const { deliveries: shown, projector: shownProjector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 64,
+ deliveryMode: "live",
+ tagVisibility: {
+ usage_update: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- shown.push({ kind, text: payload.text });
- return true;
},
});
@@ -362,15 +318,7 @@ describe("createAcpReplyProjector", () => {
});
it("hides available_commands_update by default", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg(),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
- },
- });
+ const { deliveries, projector } = createProjectorHarness();
await projector.onEvent({
type: "status",
text: "available commands updated (7)",
@@ -381,24 +329,16 @@ describe("createAcpReplyProjector", () => {
});
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- tagVisibility: {
- tool_call: true,
- tool_call_update: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ tagVisibility: {
+ tool_call: true,
+ tool_call_update: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -436,32 +376,22 @@ describe("createAcpReplyProjector", () => {
});
expect(deliveries.length).toBe(2);
- expect(deliveries[0]?.kind).toBe("tool");
- expect(deliveries[0]?.text).toContain("Tool Call");
- expect(deliveries[1]?.kind).toBe("tool");
- expect(deliveries[1]?.text).toContain("Tool Call");
+ expectToolCallSummary(deliveries[0]);
+ expectToolCallSummary(deliveries[1]);
});
it("keeps terminal tool updates even when rendered summaries are truncated", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- maxSessionUpdateChars: 48,
- tagVisibility: {
- tool_call: true,
- tool_call_update: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ maxSessionUpdateChars: 48,
+ tagVisibility: {
+ tool_call: true,
+ tool_call_update: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -485,29 +415,21 @@ describe("createAcpReplyProjector", () => {
});
expect(deliveries.length).toBe(2);
- expect(deliveries[0]?.kind).toBe("tool");
- expect(deliveries[1]?.kind).toBe("tool");
+ expectToolCallSummary(deliveries[0]);
+ expectToolCallSummary(deliveries[1]);
});
it("renders fallback tool labels without leaking call ids as primary label", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- deliveryMode: "live",
- tagVisibility: {
- tool_call: true,
- tool_call_update: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ deliveryMode: "live",
+ tagVisibility: {
+ tool_call: true,
+ tool_call_update: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -519,33 +441,25 @@ describe("createAcpReplyProjector", () => {
text: "call_ABC123 (in_progress)",
});
- expect(deliveries[0]?.text).toContain("Tool Call");
+ expectToolCallSummary(deliveries[0]);
expect(deliveries[0]?.text).not.toContain("call_ABC123 (");
});
it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- repeatSuppression: false,
- tagVisibility: {
- available_commands_update: true,
- tool_call: true,
- tool_call_update: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ repeatSuppression: false,
+ tagVisibility: {
+ available_commands_update: true,
+ tool_call: true,
+ tool_call_update: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -589,31 +503,23 @@ describe("createAcpReplyProjector", () => {
kind: "tool",
text: prefixSystemMessage("available commands updated"),
});
- expect(deliveries[2]?.text).toContain("Tool Call");
- expect(deliveries[3]?.text).toContain("Tool Call");
+ expectToolCallSummary(deliveries[2]);
+ expectToolCallSummary(deliveries[3]);
expect(deliveries[4]).toEqual({ kind: "block", text: "hello" });
});
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- tagVisibility: {
- available_commands_update: true,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ tagVisibility: {
+ available_commands_update: true,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -640,23 +546,15 @@ describe("createAcpReplyProjector", () => {
});
it("truncates oversized turns once and emits one truncation notice", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- maxOutputChars: 5,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ maxOutputChars: 5,
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -681,26 +579,18 @@ describe("createAcpReplyProjector", () => {
});
it("supports tagVisibility overrides for tool updates", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- tagVisibility: {
- tool_call: true,
- tool_call_update: false,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ tagVisibility: {
+ tool_call: true,
+ tool_call_update: false,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -722,26 +612,18 @@ describe("createAcpReplyProjector", () => {
});
expect(deliveries.length).toBe(1);
- expect(deliveries[0]?.text).toContain("Tool Call");
+ expectToolCallSummary(deliveries[0]);
});
it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -757,34 +639,22 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
- const combinedText = deliveries
- .filter((entry) => entry.kind === "block")
- .map((entry) => entry.text ?? "")
- .join("");
- expect(combinedText).toBe("fallback. I don't");
+ expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
});
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- tagVisibility: {
- tool_call: false,
- tool_call_update: false,
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ tagVisibility: {
+ tool_call: false,
+ tool_call_update: false,
},
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -808,31 +678,19 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
- const combinedText = deliveries
- .filter((entry) => entry.kind === "block")
- .map((entry) => entry.text ?? "")
- .join("");
- expect(combinedText).toBe("fallback. I don't");
+ expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
});
it("supports hiddenBoundarySeparator=space", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- hiddenBoundarySeparator: "space",
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ hiddenBoundarySeparator: "space",
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -848,31 +706,19 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
- const combinedText = deliveries
- .filter((entry) => entry.kind === "block")
- .map((entry) => entry.text ?? "")
- .join("");
- expect(combinedText).toBe("fallback. I don't");
+ expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
});
it("supports hiddenBoundarySeparator=none", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- hiddenBoundarySeparator: "none",
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
+ hiddenBoundarySeparator: "none",
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -888,30 +734,18 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
- const combinedText = deliveries
- .filter((entry) => entry.kind === "block")
- .map((entry) => entry.text ?? "")
- .join("");
- expect(combinedText).toBe("fallback.I don't");
+ expect(combinedBlockText(deliveries)).toBe("fallback.I don't");
});
it("does not duplicate newlines when previous visible text already ends with newline", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -931,30 +765,18 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "I don't", tag: "agent_message_chunk" });
await projector.flush(true);
- const combinedText = deliveries
- .filter((entry) => entry.kind === "block")
- .map((entry) => entry.text ?? "")
- .join("");
- expect(combinedText).toBe("fallback.\nI don't");
+ expect(combinedBlockText(deliveries)).toBe("fallback.\nI don't");
});
it("does not insert boundary separator for hidden non-tool status updates", async () => {
- const deliveries: Array<{ kind: string; text?: string }> = [];
- const projector = createAcpReplyProjector({
- cfg: createCfg({
- acp: {
- enabled: true,
- stream: {
- coalesceIdleMs: 0,
- maxChunkChars: 256,
- deliveryMode: "live",
- },
+ const { deliveries, projector } = createProjectorHarness({
+ acp: {
+ enabled: true,
+ stream: {
+ coalesceIdleMs: 0,
+ maxChunkChars: 256,
+ deliveryMode: "live",
},
- }),
- shouldSendToolSummaries: true,
- deliver: async (kind, payload) => {
- deliveries.push({ kind, text: payload.text });
- return true;
},
});
@@ -967,10 +789,6 @@ describe("createAcpReplyProjector", () => {
await projector.onEvent({ type: "text_delta", text: "B", tag: "agent_message_chunk" });
await projector.flush(true);
- const combinedText = deliveries
- .filter((entry) => entry.kind === "block")
- .map((entry) => entry.text ?? "")
- .join("");
- expect(combinedText).toBe("AB");
+ expect(combinedBlockText(deliveries)).toBe("AB");
});
});
diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts
index df3135f1b5b..1d808350381 100644
--- a/src/auto-reply/reply/commands-acp.test.ts
+++ b/src/auto-reply/reply/commands-acp.test.ts
@@ -52,6 +52,22 @@ const hoisted = vi.hoisted(() => {
};
});
+function createAcpCommandSessionBindingService() {
+ const forward =
+ (fn: (...args: A) => T) =>
+ (...args: A) =>
+ fn(...args);
+ return {
+ bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
+ getCapabilities: forward((params: unknown) => hoisted.sessionBindingCapabilitiesMock(params)),
+ listBySession: (targetSessionKey: string) =>
+ hoisted.sessionBindingListBySessionMock(targetSessionKey),
+ resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
+ touch: vi.fn(),
+ unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
+ };
+}
+
vi.mock("../../gateway/call.js", () => ({
callGateway: (args: unknown) => hoisted.callGatewayMock(args),
}));
@@ -79,18 +95,11 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal();
- return {
- ...actual,
- getSessionBindingService: () => ({
- bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
- getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
- listBySession: (targetSessionKey: string) =>
- hoisted.sessionBindingListBySessionMock(targetSessionKey),
- resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
- touch: vi.fn(),
- unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
- }),
+ const patched = { ...actual } as typeof actual & {
+ getSessionBindingService: () => ReturnType;
};
+ patched.getSessionBindingService = () => createAcpCommandSessionBindingService();
+ return patched;
});
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
@@ -172,6 +181,128 @@ function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg)
return params;
}
+const defaultAcpSessionKey = "agent:codex:acp:s1";
+const defaultThreadId = "thread-1";
+
+type AcpSessionIdentity = {
+ state: "resolved";
+ source: "status";
+ acpxSessionId: string;
+ agentSessionId: string;
+ lastUpdatedAt: number;
+};
+
+function createThreadConversation(conversationId: string = defaultThreadId) {
+ return {
+ channel: "discord" as const,
+ accountId: "default",
+ conversationId,
+ parentConversationId: "parent-1",
+ };
+}
+
+function createBoundThreadSession(sessionKey: string = defaultAcpSessionKey) {
+ return createSessionBinding({
+ targetSessionKey: sessionKey,
+ conversation: createThreadConversation(),
+ });
+}
+
+function createAcpSessionEntry(options?: {
+ sessionKey?: string;
+ state?: "idle" | "running";
+ identity?: AcpSessionIdentity;
+}) {
+ const sessionKey = options?.sessionKey ?? defaultAcpSessionKey;
+ return {
+ sessionKey,
+ storeSessionKey: sessionKey,
+ acp: {
+ backend: "acpx",
+ agent: "codex",
+ runtimeSessionName: "runtime-1",
+ ...(options?.identity ? { identity: options.identity } : {}),
+ mode: "persistent",
+ state: options?.state ?? "idle",
+ lastActivityAt: Date.now(),
+ },
+ };
+}
+
+function createSessionBindingCapabilities() {
+ return {
+ adapterAvailable: true,
+ bindSupported: true,
+ unbindSupported: true,
+ placements: ["current", "child"] as const,
+ };
+}
+
+type AcpBindInput = {
+ targetSessionKey: string;
+ conversation: { accountId: string; conversationId: string };
+ placement: "current" | "child";
+ metadata?: Record;
+};
+
+function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
+ const nextConversationId =
+ input.placement === "child" ? "thread-created" : input.conversation.conversationId;
+ const boundBy = typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1";
+ return createSessionBinding({
+ targetSessionKey: input.targetSessionKey,
+ conversation: {
+ channel: "discord",
+ accountId: input.conversation.accountId,
+ conversationId: nextConversationId,
+ parentConversationId: "parent-1",
+ },
+ metadata: { boundBy, webhookId: "wh-1" },
+ });
+}
+
+function expectBoundIntroTextToExclude(match: string): void {
+ const calls = hoisted.sessionBindingBindMock.mock.calls as Array<
+ [{ metadata?: { introText?: unknown } }]
+ >;
+ const introText = calls
+ .map((call) => call[0]?.metadata?.introText)
+ .find((value): value is string => typeof value === "string");
+ expect((introText ?? "").includes(match)).toBe(false);
+}
+
+function mockBoundThreadSession(options?: {
+ sessionKey?: string;
+ state?: "idle" | "running";
+ identity?: AcpSessionIdentity;
+}) {
+ const sessionKey = options?.sessionKey ?? defaultAcpSessionKey;
+ hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
+ createBoundThreadSession(sessionKey),
+ );
+ hoisted.readAcpSessionEntryMock.mockReturnValue(
+ createAcpSessionEntry({
+ sessionKey,
+ state: options?.state,
+ identity: options?.identity,
+ }),
+ );
+}
+
+function createThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
+ const params = createDiscordParams(commandBody, cfg);
+ params.ctx.MessageThreadId = defaultThreadId;
+ return params;
+}
+
+async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
+ return handleAcpCommand(createDiscordParams(commandBody, cfg), true);
+}
+
+async function runThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
+ return handleAcpCommand(createThreadParams(commandBody, cfg), true);
+}
+
describe("/acp command", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
@@ -195,37 +326,12 @@ describe("/acp command", () => {
storePath: "/tmp/sessions-acp.json",
});
hoisted.loadSessionStoreMock.mockReset().mockReturnValue({});
- hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
- adapterAvailable: true,
- bindSupported: true,
- unbindSupported: true,
- placements: ["current", "child"],
- });
+ hoisted.sessionBindingCapabilitiesMock
+ .mockReset()
+ .mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingBindMock
.mockReset()
- .mockImplementation(
- async (input: {
- targetSessionKey: string;
- conversation: { accountId: string; conversationId: string };
- placement: "current" | "child";
- metadata?: Record;
- }) =>
- createSessionBinding({
- targetSessionKey: input.targetSessionKey,
- conversation: {
- channel: "discord",
- accountId: input.conversation.accountId,
- conversationId:
- input.placement === "child" ? "thread-created" : input.conversation.conversationId,
- parentConversationId: "parent-1",
- },
- metadata: {
- boundBy:
- typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
- webhookId: "wh-1",
- },
- }),
- );
+ .mockImplementation(async (input: AcpBindInput) => createAcpThreadBinding(input));
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
@@ -275,14 +381,12 @@ describe("/acp command", () => {
});
it("returns null when the message is not /acp", async () => {
- const params = createDiscordParams("/status");
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/status");
expect(result).toBeNull();
});
it("shows help by default", async () => {
- const params = createDiscordParams("/acp");
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp");
expect(result?.reply?.text).toContain("ACP commands:");
expect(result?.reply?.text).toContain("/acp spawn");
});
@@ -296,8 +400,7 @@ describe("/acp command", () => {
backendSessionId: "acpx-1",
});
- const params = createDiscordParams("/acp spawn codex --cwd /home/bob/clawd");
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp spawn codex --cwd /home/bob/clawd");
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
@@ -318,15 +421,7 @@ describe("/acp command", () => {
}),
}),
);
- expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
- expect.objectContaining({
- metadata: expect.objectContaining({
- introText: expect.not.stringContaining(
- "session ids: pending (available after the first reply)",
- ),
- }),
- }),
- );
+ expectBoundIntroTextToExclude("session ids: pending (available after the first reply)");
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.patch",
@@ -352,8 +447,7 @@ describe("/acp command", () => {
});
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
- const params = createDiscordParams("/acp spawn");
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp spawn");
expect(result?.reply?.text).toContain("ACP target agent is required");
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
@@ -372,8 +466,7 @@ describe("/acp command", () => {
},
} satisfies OpenClawConfig;
- const params = createDiscordParams("/acp spawn codex", cfg);
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
@@ -393,38 +486,14 @@ describe("/acp command", () => {
});
it("cancels the ACP session bound to the current thread", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
+ mockBoundThreadSession({ state: "running" });
+ const result = await runThreadAcpCommand("/acp cancel", baseCfg);
+ expect(result?.reply?.text).toContain(
+ `Cancel requested for ACP session ${defaultAcpSessionKey}`,
);
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "running",
- lastActivityAt: Date.now(),
- },
- });
-
- const params = createDiscordParams("/acp cancel", baseCfg);
- params.ctx.MessageThreadId = "thread-1";
-
- const result = await handleAcpCommand(params, true);
- expect(result?.reply?.text).toContain("Cancel requested for ACP session agent:codex:acp:s1");
expect(hoisted.cancelMock).toHaveBeenCalledWith({
handle: expect.objectContaining({
- sessionKey: "agent:codex:acp:s1",
+ sessionKey: defaultAcpSessionKey,
backend: "acpx",
}),
reason: "manual-cancel",
@@ -434,29 +503,19 @@ describe("/acp command", () => {
it("sends steer instructions via ACP runtime", async () => {
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "sessions.resolve") {
- return { key: "agent:codex:acp:s1" };
+ return { key: defaultAcpSessionKey };
}
return { ok: true };
});
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
- },
- });
+ hoisted.readAcpSessionEntryMock.mockReturnValue(createAcpSessionEntry());
hoisted.runTurnMock.mockImplementation(async function* () {
yield { type: "text_delta", text: "Applied steering." };
yield { type: "done" };
});
- const params = createDiscordParams("/acp steer --session agent:codex:acp:s1 tighten logging");
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand(
+ `/acp steer --session ${defaultAcpSessionKey} tighten logging`,
+ );
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -475,57 +534,23 @@ describe("/acp command", () => {
dispatch: { enabled: false },
},
} satisfies OpenClawConfig;
- const params = createDiscordParams("/acp steer tighten logging", cfg);
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp steer tighten logging", cfg);
expect(result?.reply?.text).toContain("ACP dispatch is disabled by policy");
expect(hoisted.runTurnMock).not.toHaveBeenCalled();
});
it("closes an ACP session, unbinds thread targets, and clears metadata", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
- );
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
- },
- });
+ mockBoundThreadSession();
hoisted.sessionBindingUnbindMock.mockResolvedValue([
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }) as SessionBindingRecord,
+ createBoundThreadSession() as SessionBindingRecord,
]);
- const params = createDiscordParams("/acp close", baseCfg);
- params.ctx.MessageThreadId = "thread-1";
-
- const result = await handleAcpCommand(params, true);
+ const result = await runThreadAcpCommand("/acp close", baseCfg);
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith(
expect.objectContaining({
- targetSessionKey: "agent:codex:acp:s1",
+ targetSessionKey: defaultAcpSessionKey,
reason: "manual",
}),
);
@@ -535,22 +560,10 @@ describe("/acp command", () => {
it("lists ACP sessions from the session store", async () => {
hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) =>
- key === "agent:codex:acp:s1"
- ? [
- createSessionBinding({
- targetSessionKey: key,
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }) as SessionBindingRecord,
- ]
- : [],
+ key === defaultAcpSessionKey ? [createBoundThreadSession(key) as SessionBindingRecord] : [],
);
hoisted.loadSessionStoreMock.mockReturnValue({
- "agent:codex:acp:s1": {
+ [defaultAcpSessionKey]: {
sessionId: "sess-1",
updatedAt: Date.now(),
label: "codex-main",
@@ -569,52 +582,27 @@ describe("/acp command", () => {
},
});
- const params = createDiscordParams("/acp sessions", baseCfg);
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp sessions", baseCfg);
expect(result?.reply?.text).toContain("ACP sessions:");
expect(result?.reply?.text).toContain("codex-main");
- expect(result?.reply?.text).toContain("thread:thread-1");
+ expect(result?.reply?.text).toContain(`thread:${defaultThreadId}`);
});
it("shows ACP status for the thread-bound ACP session", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
- );
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- identity: {
- state: "resolved",
- source: "status",
- acpxSessionId: "acpx-sid-1",
- agentSessionId: "codex-sid-1",
- lastUpdatedAt: Date.now(),
- },
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
+ mockBoundThreadSession({
+ identity: {
+ state: "resolved",
+ source: "status",
+ acpxSessionId: "acpx-sid-1",
+ agentSessionId: "codex-sid-1",
+ lastUpdatedAt: Date.now(),
},
});
- const params = createDiscordParams("/acp status", baseCfg);
- params.ctx.MessageThreadId = "thread-1";
-
- const result = await handleAcpCommand(params, true);
+ const result = await runThreadAcpCommand("/acp status", baseCfg);
expect(result?.reply?.text).toContain("ACP status:");
- expect(result?.reply?.text).toContain("session: agent:codex:acp:s1");
+ expect(result?.reply?.text).toContain(`session: ${defaultAcpSessionKey}`);
expect(result?.reply?.text).toContain("agent session id: codex-sid-1");
expect(result?.reply?.text).toContain("acpx session id: acpx-sid-1");
expect(result?.reply?.text).toContain("capabilities:");
@@ -622,33 +610,8 @@ describe("/acp command", () => {
});
it("updates ACP runtime mode via /acp set-mode", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
- );
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
- },
- });
- const params = createDiscordParams("/acp set-mode plan", baseCfg);
- params.ctx.MessageThreadId = "thread-1";
-
- const result = await handleAcpCommand(params, true);
+ mockBoundThreadSession();
+ const result = await runThreadAcpCommand("/acp set-mode plan", baseCfg);
expect(hoisted.setModeMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -659,33 +622,9 @@ describe("/acp command", () => {
});
it("updates ACP config options and keeps cwd local when using /acp set", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
- );
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
- },
- });
+ mockBoundThreadSession();
- const setModelParams = createDiscordParams("/acp set model gpt-5.3-codex", baseCfg);
- setModelParams.ctx.MessageThreadId = "thread-1";
- const setModel = await handleAcpCommand(setModelParams, true);
+ const setModel = await runThreadAcpCommand("/acp set model gpt-5.3-codex", baseCfg);
expect(hoisted.setConfigOptionMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "model",
@@ -695,74 +634,24 @@ describe("/acp command", () => {
expect(setModel?.reply?.text).toContain("Updated ACP config option");
hoisted.setConfigOptionMock.mockClear();
- const setCwdParams = createDiscordParams("/acp set cwd /tmp/worktree", baseCfg);
- setCwdParams.ctx.MessageThreadId = "thread-1";
- const setCwd = await handleAcpCommand(setCwdParams, true);
+ const setCwd = await runThreadAcpCommand("/acp set cwd /tmp/worktree", baseCfg);
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
expect(setCwd?.reply?.text).toContain("Updated ACP cwd");
});
it("rejects non-absolute cwd values via ACP runtime option validation", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
- );
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
- },
- });
+ mockBoundThreadSession();
- const params = createDiscordParams("/acp cwd relative/path", baseCfg);
- params.ctx.MessageThreadId = "thread-1";
- const result = await handleAcpCommand(params, true);
+ const result = await runThreadAcpCommand("/acp cwd relative/path", baseCfg);
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
expect(result?.reply?.text).toContain("absolute path");
});
it("rejects invalid timeout values before backend config writes", async () => {
- hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
- createSessionBinding({
- targetSessionKey: "agent:codex:acp:s1",
- conversation: {
- channel: "discord",
- accountId: "default",
- conversationId: "thread-1",
- parentConversationId: "parent-1",
- },
- }),
- );
- hoisted.readAcpSessionEntryMock.mockReturnValue({
- sessionKey: "agent:codex:acp:s1",
- storeSessionKey: "agent:codex:acp:s1",
- acp: {
- backend: "acpx",
- agent: "codex",
- runtimeSessionName: "runtime-1",
- mode: "persistent",
- state: "idle",
- lastActivityAt: Date.now(),
- },
- });
+ mockBoundThreadSession();
- const params = createDiscordParams("/acp timeout 10s", baseCfg);
- params.ctx.MessageThreadId = "thread-1";
- const result = await handleAcpCommand(params, true);
+ const result = await runThreadAcpCommand("/acp timeout 10s", baseCfg);
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
@@ -777,8 +666,7 @@ describe("/acp command", () => {
);
});
- const params = createDiscordParams("/acp doctor", baseCfg);
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp doctor", baseCfg);
expect(result?.reply?.text).toContain("ACP doctor:");
expect(result?.reply?.text).toContain("healthy: no");
@@ -786,8 +674,7 @@ describe("/acp command", () => {
});
it("shows deterministic install instructions via /acp install", async () => {
- const params = createDiscordParams("/acp install", baseCfg);
- const result = await handleAcpCommand(params, true);
+ const result = await runDiscordAcpCommand("/acp install", baseCfg);
expect(result?.reply?.text).toContain("ACP install:");
expect(result?.reply?.text).toContain("run:");
diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts
index 7a9f5ca34cc..70a7c038767 100644
--- a/src/auto-reply/reply/commands-subagents-focus.test.ts
+++ b/src/auto-reply/reply/commands-subagents-focus.test.ts
@@ -30,6 +30,28 @@ const hoisted = vi.hoisted(() => {
};
});
+function buildFocusSessionBindingService() {
+ const service = {
+ touch: vi.fn(),
+ listBySession(targetSessionKey: string) {
+ return hoisted.sessionBindingListBySessionMock(targetSessionKey);
+ },
+ resolveByConversation(ref: unknown) {
+ return hoisted.sessionBindingResolveByConversationMock(ref);
+ },
+ getCapabilities(params: unknown) {
+ return hoisted.sessionBindingCapabilitiesMock(params);
+ },
+ bind(input: unknown) {
+ return hoisted.sessionBindingBindMock(input);
+ },
+ unbind(input: unknown) {
+ return hoisted.sessionBindingUnbindMock(input);
+ },
+ };
+ return service;
+}
+
vi.mock("../../gateway/call.js", () => ({
callGateway: hoisted.callGatewayMock,
}));
@@ -56,15 +78,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal
await importOriginal();
return {
...actual,
- getSessionBindingService: () => ({
- bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
- getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
- listBySession: (targetSessionKey: string) =>
- hoisted.sessionBindingListBySessionMock(targetSessionKey),
- resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
- touch: vi.fn(),
- unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
- }),
+ getSessionBindingService: () => buildFocusSessionBindingService(),
};
});
@@ -217,13 +231,33 @@ function createSessionBindingRecord(
};
}
-async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
- hoisted.sessionBindingCapabilitiesMock.mockReturnValue({
+function createSessionBindingCapabilities() {
+ return {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
- placements: ["current", "child"],
- });
+ placements: ["current", "child"] as const,
+ };
+}
+
+async function runUnfocusAndExpectManualUnbind(initialBindings: FakeBinding[]) {
+ const fake = createFakeThreadBindingManager(initialBindings);
+ hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
+
+ const params = createDiscordCommandParams("/unfocus");
+ const result = await handleSubagentsCommand(params, true);
+
+ expect(result?.reply?.text).toContain("Thread unfocused");
+ expect(fake.manager.unbindThread).toHaveBeenCalledWith(
+ expect.objectContaining({
+ threadId: "thread-1",
+ reason: "manual",
+ }),
+ );
+}
+
+async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
+ hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(options?.existingBinding ?? null);
hoisted.sessionBindingBindMock.mockImplementation(
async (input: {
@@ -256,6 +290,12 @@ async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindin
return { result };
}
+async function runAgentsCommandAndText(): Promise {
+ const params = createDiscordCommandParams("/agents");
+ const result = await handleSubagentsCommand(params, true);
+ return result?.reply?.text ?? "";
+}
+
describe("/focus, /unfocus, /agents", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
@@ -263,12 +303,9 @@ describe("/focus, /unfocus, /agents", () => {
hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null);
hoisted.resolveThreadBindingThreadNameMock.mockClear().mockReturnValue("🤖 codex");
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
- hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
- adapterAvailable: true,
- bindSupported: true,
- unbindSupported: true,
- placements: ["current", "child"],
- });
+ hoisted.sessionBindingCapabilitiesMock
+ .mockReset()
+ .mockReturnValue(createSessionBindingCapabilities());
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
@@ -340,23 +377,11 @@ describe("/focus, /unfocus, /agents", () => {
});
it("/unfocus removes an active thread binding for the binding owner", async () => {
- const fake = createFakeThreadBindingManager([createStoredBinding()]);
- hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
-
- const params = createDiscordCommandParams("/unfocus");
- const result = await handleSubagentsCommand(params, true);
-
- expect(result?.reply?.text).toContain("Thread unfocused");
- expect(fake.manager.unbindThread).toHaveBeenCalledWith(
- expect.objectContaining({
- threadId: "thread-1",
- reason: "manual",
- }),
- );
+ await runUnfocusAndExpectManualUnbind([createStoredBinding()]);
});
it("/unfocus also unbinds ACP-focused thread bindings", async () => {
- const fake = createFakeThreadBindingManager([
+ await runUnfocusAndExpectManualUnbind([
createStoredBinding({
targetKind: "acp",
targetSessionKey: "agent:codex:acp:session-1",
@@ -364,18 +389,6 @@ describe("/focus, /unfocus, /agents", () => {
label: "codex-session",
}),
]);
- hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
-
- const params = createDiscordCommandParams("/unfocus");
- const result = await handleSubagentsCommand(params, true);
-
- expect(result?.reply?.text).toContain("Thread unfocused");
- expect(fake.manager.unbindThread).toHaveBeenCalledWith(
- expect.objectContaining({
- threadId: "thread-1",
- reason: "manual",
- }),
- );
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
@@ -428,9 +441,7 @@ describe("/focus, /unfocus, /agents", () => {
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
- const params = createDiscordCommandParams("/agents");
- const result = await handleSubagentsCommand(params, true);
- const text = result?.reply?.text ?? "";
+ const text = await runAgentsCommandAndText();
expect(text).toContain("agents:");
expect(text).toContain("thread:thread-1");
@@ -464,9 +475,7 @@ describe("/focus, /unfocus, /agents", () => {
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
- const params = createDiscordCommandParams("/agents");
- const result = await handleSubagentsCommand(params, true);
- const text = result?.reply?.text ?? "";
+ const text = await runAgentsCommandAndText();
expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1");
});
diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts
index 26733136ad0..ce02f98289d 100644
--- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts
+++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts
@@ -26,21 +26,25 @@ function createDispatcher(): ReplyDispatcher {
};
}
+function createCoordinator(onReplyStart?: (...args: unknown[]) => Promise) {
+ return createAcpDispatchDeliveryCoordinator({
+ cfg: createAcpTestConfig(),
+ ctx: buildTestCtx({
+ Provider: "discord",
+ Surface: "discord",
+ SessionKey: "agent:codex-acp:session-1",
+ }),
+ dispatcher: createDispatcher(),
+ inboundAudio: false,
+ shouldRouteToOriginating: false,
+ ...(onReplyStart ? { onReplyStart } : {}),
+ });
+}
+
describe("createAcpDispatchDeliveryCoordinator", () => {
it("starts reply lifecycle only once when called directly and through deliver", async () => {
const onReplyStart = vi.fn(async () => {});
- const coordinator = createAcpDispatchDeliveryCoordinator({
- cfg: createAcpTestConfig(),
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- }),
- dispatcher: createDispatcher(),
- inboundAudio: false,
- shouldRouteToOriginating: false,
- onReplyStart,
- });
+ const coordinator = createCoordinator(onReplyStart);
await coordinator.startReplyLifecycle();
await coordinator.deliver("final", { text: "hello" });
@@ -52,18 +56,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
it("starts reply lifecycle once when deliver triggers first", async () => {
const onReplyStart = vi.fn(async () => {});
- const coordinator = createAcpDispatchDeliveryCoordinator({
- cfg: createAcpTestConfig(),
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- }),
- dispatcher: createDispatcher(),
- inboundAudio: false,
- shouldRouteToOriginating: false,
- onReplyStart,
- });
+ const coordinator = createCoordinator(onReplyStart);
await coordinator.deliver("final", { text: "hello" });
await coordinator.startReplyLifecycle();
@@ -73,18 +66,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
it("does not start reply lifecycle for empty payload delivery", async () => {
const onReplyStart = vi.fn(async () => {});
- const coordinator = createAcpDispatchDeliveryCoordinator({
- cfg: createAcpTestConfig(),
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- }),
- dispatcher: createDispatcher(),
- inboundAudio: false,
- shouldRouteToOriginating: false,
- onReplyStart,
- });
+ const coordinator = createCoordinator(onReplyStart);
await coordinator.deliver("final", {});
diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts
index 922dc5d5d40..286b73a7ceb 100644
--- a/src/auto-reply/reply/dispatch-acp.test.ts
+++ b/src/auto-reply/reply/dispatch-acp.test.ts
@@ -85,6 +85,7 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
}));
const { tryDispatchAcpReply } = await import("./dispatch-acp.js");
+const sessionKey = "agent:codex-acp:session-1";
function createDispatcher(): {
dispatcher: ReplyDispatcher;
@@ -105,7 +106,7 @@ function createDispatcher(): {
function setReadyAcpResolution() {
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
- sessionKey: "agent:codex-acp:session-1",
+ sessionKey,
meta: createAcpSessionMeta(),
});
}
@@ -124,6 +125,84 @@ function createAcpConfigWithVisibleToolTags(): OpenClawConfig {
});
}
+async function runDispatch(params: {
+ bodyForAgent: string;
+ cfg?: OpenClawConfig;
+ dispatcher?: ReplyDispatcher;
+ shouldRouteToOriginating?: boolean;
+ onReplyStart?: () => void;
+}) {
+ return tryDispatchAcpReply({
+ ctx: buildTestCtx({
+ Provider: "discord",
+ Surface: "discord",
+ SessionKey: sessionKey,
+ BodyForAgent: params.bodyForAgent,
+ }),
+ cfg: params.cfg ?? createAcpTestConfig(),
+ dispatcher: params.dispatcher ?? createDispatcher().dispatcher,
+ sessionKey,
+ inboundAudio: false,
+ shouldRouteToOriginating: params.shouldRouteToOriginating ?? false,
+ ...(params.shouldRouteToOriginating
+ ? { originatingChannel: "telegram", originatingTo: "telegram:thread-1" }
+ : {}),
+ shouldSendToolSummaries: true,
+ bypassForCommand: false,
+ ...(params.onReplyStart ? { onReplyStart: params.onReplyStart } : {}),
+ recordProcessed: vi.fn(),
+ markIdle: vi.fn(),
+ });
+}
+
+async function emitToolLifecycleEvents(
+ onEvent: (event: unknown) => Promise,
+ toolCallId: string,
+) {
+ await onEvent({
+ type: "tool_call",
+ tag: "tool_call",
+ toolCallId,
+ status: "in_progress",
+ title: "Run command",
+ text: "Run command (in_progress)",
+ });
+ await onEvent({
+ type: "tool_call",
+ tag: "tool_call_update",
+ toolCallId,
+ status: "completed",
+ title: "Run command",
+ text: "Run command (completed)",
+ });
+ await onEvent({ type: "done" });
+}
+
+function mockToolLifecycleTurn(toolCallId: string) {
+ managerMocks.runTurn.mockImplementation(
+ async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => {
+ await emitToolLifecycleEvents(onEvent, toolCallId);
+ },
+ );
+}
+
+function mockVisibleTextTurn(text = "visible") {
+ managerMocks.runTurn.mockImplementationOnce(
+ async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => {
+ await onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
+ await onEvent({ type: "done" });
+ },
+ );
+}
+
+async function dispatchVisibleTurn(onReplyStart: () => void) {
+ await runDispatch({
+ bodyForAgent: "visible",
+ dispatcher: createDispatcher().dispatcher,
+ onReplyStart,
+ });
+}
+
describe("tryDispatchAcpReply", () => {
beforeEach(() => {
managerMocks.resolveSession.mockReset();
@@ -160,24 +239,10 @@ describe("tryDispatchAcpReply", () => {
);
const { dispatcher } = createDispatcher();
- const result = await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "reply",
- }),
- cfg: createAcpTestConfig(),
+ const result = await runDispatch({
+ bodyForAgent: "reply",
dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
shouldRouteToOriginating: true,
- originatingChannel: "telegram",
- originatingTo: "telegram:thread-1",
- shouldSendToolSummaries: true,
- bypassForCommand: false,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
});
expect(result?.counts.block).toBe(1);
@@ -192,48 +257,15 @@ describe("tryDispatchAcpReply", () => {
it("edits ACP tool lifecycle updates in place when supported", async () => {
setReadyAcpResolution();
- managerMocks.runTurn.mockImplementation(
- async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => {
- await onEvent({
- type: "tool_call",
- tag: "tool_call",
- toolCallId: "call-1",
- status: "in_progress",
- title: "Run command",
- text: "Run command (in_progress)",
- });
- await onEvent({
- type: "tool_call",
- tag: "tool_call_update",
- toolCallId: "call-1",
- status: "completed",
- title: "Run command",
- text: "Run command (completed)",
- });
- await onEvent({ type: "done" });
- },
- );
+ mockToolLifecycleTurn("call-1");
routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" });
const { dispatcher } = createDispatcher();
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "run tool",
- }),
+ await runDispatch({
+ bodyForAgent: "run tool",
cfg: createAcpConfigWithVisibleToolTags(),
dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
shouldRouteToOriginating: true,
- originatingChannel: "telegram",
- originatingTo: "telegram:thread-1",
- shouldSendToolSummaries: true,
- bypassForCommand: false,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
});
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
@@ -249,51 +281,18 @@ describe("tryDispatchAcpReply", () => {
it("falls back to new tool message when edit fails", async () => {
setReadyAcpResolution();
- managerMocks.runTurn.mockImplementation(
- async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => {
- await onEvent({
- type: "tool_call",
- tag: "tool_call",
- toolCallId: "call-2",
- status: "in_progress",
- title: "Run command",
- text: "Run command (in_progress)",
- });
- await onEvent({
- type: "tool_call",
- tag: "tool_call_update",
- toolCallId: "call-2",
- status: "completed",
- title: "Run command",
- text: "Run command (completed)",
- });
- await onEvent({ type: "done" });
- },
- );
+ mockToolLifecycleTurn("call-2");
routeMocks.routeReply
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" });
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
const { dispatcher } = createDispatcher();
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "run tool",
- }),
+ await runDispatch({
+ bodyForAgent: "run tool",
cfg: createAcpConfigWithVisibleToolTags(),
dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
shouldRouteToOriginating: true,
- originatingChannel: "telegram",
- originatingTo: "telegram:thread-1",
- shouldSendToolSummaries: true,
- bypassForCommand: false,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
});
expect(messageActionMocks.runMessageAction).toHaveBeenCalledTimes(1);
@@ -317,50 +316,15 @@ describe("tryDispatchAcpReply", () => {
await onEvent({ type: "done" });
},
);
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "hidden",
- }),
- cfg: createAcpTestConfig(),
+ await runDispatch({
+ bodyForAgent: "hidden",
dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
- shouldRouteToOriginating: false,
- shouldSendToolSummaries: true,
- bypassForCommand: false,
onReplyStart,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
});
expect(onReplyStart).toHaveBeenCalledTimes(1);
- managerMocks.runTurn.mockImplementationOnce(
- async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => {
- await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" });
- await onEvent({ type: "done" });
- },
- );
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "visible",
- }),
- cfg: createAcpTestConfig(),
- dispatcher: createDispatcher().dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
- shouldRouteToOriginating: false,
- shouldSendToolSummaries: true,
- bypassForCommand: false,
- onReplyStart,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
- });
+ mockVisibleTextTurn();
+ await dispatchVisibleTurn(onReplyStart);
expect(onReplyStart).toHaveBeenCalledTimes(2);
});
@@ -368,31 +332,8 @@ describe("tryDispatchAcpReply", () => {
setReadyAcpResolution();
const onReplyStart = vi.fn();
- managerMocks.runTurn.mockImplementationOnce(
- async ({ onEvent }: { onEvent: (event: unknown) => Promise }) => {
- await onEvent({ type: "text_delta", text: "visible", tag: "agent_message_chunk" });
- await onEvent({ type: "done" });
- },
- );
-
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "visible",
- }),
- cfg: createAcpTestConfig(),
- dispatcher: createDispatcher().dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
- shouldRouteToOriginating: false,
- shouldSendToolSummaries: true,
- bypassForCommand: false,
- onReplyStart,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
- });
+ mockVisibleTextTurn();
+ await dispatchVisibleTurn(onReplyStart);
expect(onReplyStart).toHaveBeenCalledTimes(1);
});
@@ -402,23 +343,10 @@ describe("tryDispatchAcpReply", () => {
const onReplyStart = vi.fn();
const { dispatcher } = createDispatcher();
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: " ",
- }),
- cfg: createAcpTestConfig(),
+ await runDispatch({
+ bodyForAgent: " ",
dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
- shouldRouteToOriginating: false,
- shouldSendToolSummaries: true,
- bypassForCommand: false,
onReplyStart,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
});
expect(managerMocks.runTurn).not.toHaveBeenCalled();
@@ -432,22 +360,9 @@ describe("tryDispatchAcpReply", () => {
);
const { dispatcher } = createDispatcher();
- await tryDispatchAcpReply({
- ctx: buildTestCtx({
- Provider: "discord",
- Surface: "discord",
- SessionKey: "agent:codex-acp:session-1",
- BodyForAgent: "test",
- }),
- cfg: createAcpTestConfig(),
+ await runDispatch({
+ bodyForAgent: "test",
dispatcher,
- sessionKey: "agent:codex-acp:session-1",
- inboundAudio: false,
- shouldRouteToOriginating: false,
- shouldSendToolSummaries: true,
- bypassForCommand: false,
- recordProcessed: vi.fn(),
- markIdle: vi.fn(),
});
expect(managerMocks.runTurn).not.toHaveBeenCalled();
diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts
index a6e0c9f849a..ae737b68fe3 100644
--- a/src/auto-reply/reply/followup-runner.test.ts
+++ b/src/auto-reply/reply/followup-runner.test.ts
@@ -113,6 +113,10 @@ function mockCompactionRun(params: {
);
}
+function createAsyncReplySpy() {
+ return vi.fn(async () => {});
+}
+
describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join(
@@ -181,92 +185,97 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
}
- it("drops payloads already sent via messaging tool", async () => {
- const onBlockReply = vi.fn(async () => {});
+ async function runMessagingCase(params: {
+ agentResult: Record;
+ queued?: FollowupRun;
+ runnerOverrides?: Partial<{
+ sessionEntry: SessionEntry;
+ sessionStore: Record;
+ sessionKey: string;
+ storePath: string;
+ }>;
+ }) {
+ const onBlockReply = createAsyncReplySpy();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- messagingToolSentTexts: ["hello world!"],
meta: {},
+ ...params.agentResult,
});
+ const runner = createMessagingDedupeRunner(onBlockReply, params.runnerOverrides);
+ await runner(params.queued ?? baseQueuedRun());
+ return { onBlockReply };
+ }
- const runner = createMessagingDedupeRunner(onBlockReply);
+ function makeTextReplyDedupeResult(overrides?: Record) {
+ return {
+ payloads: [{ text: "hello world!" }],
+ messagingToolSentTexts: ["different message"],
+ ...overrides,
+ };
+ }
- await runner(baseQueuedRun());
+ it("drops payloads already sent via messaging tool", async () => {
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ payloads: [{ text: "hello world!" }],
+ messagingToolSentTexts: ["hello world!"],
+ },
+ });
expect(onBlockReply).not.toHaveBeenCalled();
});
it("delivers payloads when not duplicates", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- messagingToolSentTexts: ["different message"],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: makeTextReplyDedupeResult(),
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner(baseQueuedRun());
-
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
it("suppresses replies when a messaging tool sent via the same provider + target", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- messagingToolSentTexts: ["different message"],
- messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ ...makeTextReplyDedupeResult(),
+ messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
+ },
+ queued: baseQueuedRun("slack"),
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner(baseQueuedRun("slack"));
-
expect(onBlockReply).not.toHaveBeenCalled();
});
it("suppresses replies when provider is synthetic but originating channel matches", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- messagingToolSentTexts: ["different message"],
- messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ ...makeTextReplyDedupeResult(),
+ messagingToolSentTargets: [{ tool: "telegram", provider: "telegram", to: "268300329" }],
+ },
+ queued: {
+ ...baseQueuedRun("heartbeat"),
+ originatingChannel: "telegram",
+ originatingTo: "268300329",
+ } as FollowupRun,
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner({
- ...baseQueuedRun("heartbeat"),
- originatingChannel: "telegram",
- originatingTo: "268300329",
- } as FollowupRun);
-
expect(onBlockReply).not.toHaveBeenCalled();
});
it("does not suppress replies for same target when account differs", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- messagingToolSentTexts: ["different message"],
- messagingToolSentTargets: [
- { tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
- ],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ ...makeTextReplyDedupeResult(),
+ messagingToolSentTargets: [
+ { tool: "telegram", provider: "telegram", to: "268300329", accountId: "work" },
+ ],
+ },
+ queued: {
+ ...baseQueuedRun("heartbeat"),
+ originatingChannel: "telegram",
+ originatingTo: "268300329",
+ originatingAccountId: "personal",
+ } as FollowupRun,
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner({
- ...baseQueuedRun("heartbeat"),
- originatingChannel: "telegram",
- originatingTo: "268300329",
- originatingAccountId: "personal",
- } as FollowupRun);
-
expect(routeReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
@@ -278,33 +287,25 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
it("drops media URL from payload when messaging tool already sent it", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ mediaUrl: "/tmp/img.png" }],
- messagingToolSentMediaUrls: ["/tmp/img.png"],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ payloads: [{ mediaUrl: "/tmp/img.png" }],
+ messagingToolSentMediaUrls: ["/tmp/img.png"],
+ },
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner(baseQueuedRun());
-
// Media stripped → payload becomes non-renderable → not delivered.
expect(onBlockReply).not.toHaveBeenCalled();
});
it("delivers media payload when not a duplicate", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ mediaUrl: "/tmp/img.png" }],
- messagingToolSentMediaUrls: ["/tmp/other.png"],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ payloads: [{ mediaUrl: "/tmp/img.png" }],
+ messagingToolSentMediaUrls: ["/tmp/other.png"],
+ },
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner(baseQueuedRun());
-
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
@@ -318,30 +319,28 @@ describe("createFollowupRunner messaging tool dedupe", () => {
const sessionStore: Record = { [sessionKey]: sessionEntry };
await saveSessionStore(storePath, sessionStore);
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- messagingToolSentTexts: ["different message"],
- messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
- meta: {
- agentMeta: {
- usage: { input: 1_000, output: 50 },
- lastCallUsage: { input: 400, output: 20 },
- model: "claude-opus-4-5",
- provider: "anthropic",
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: {
+ ...makeTextReplyDedupeResult(),
+ messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
+ meta: {
+ agentMeta: {
+ usage: { input: 1_000, output: 50 },
+ lastCallUsage: { input: 400, output: 20 },
+ model: "claude-opus-4-5",
+ provider: "anthropic",
+ },
},
},
+ runnerOverrides: {
+ sessionEntry,
+ sessionStore,
+ sessionKey,
+ storePath,
+ },
+ queued: baseQueuedRun("slack"),
});
- const runner = createMessagingDedupeRunner(onBlockReply, {
- sessionEntry,
- sessionStore,
- sessionKey,
- storePath,
- });
-
- await runner(baseQueuedRun("slack"));
-
expect(onBlockReply).not.toHaveBeenCalled();
const store = loadSessionStore(storePath, { skipCache: true });
// totalTokens should reflect the last call usage snapshot, not the accumulated input.
@@ -353,46 +352,36 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
it("does not fall back to dispatcher when cross-channel origin routing fails", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- meta: {},
- });
routeReplyMock.mockResolvedValueOnce({
ok: false,
error: "forced route failure",
});
-
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner({
- ...baseQueuedRun("webchat"),
- originatingChannel: "discord",
- originatingTo: "channel:C1",
- } as FollowupRun);
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: { payloads: [{ text: "hello world!" }] },
+ queued: {
+ ...baseQueuedRun("webchat"),
+ originatingChannel: "discord",
+ originatingTo: "channel:C1",
+ } as FollowupRun,
+ });
expect(routeReplyMock).toHaveBeenCalled();
expect(onBlockReply).not.toHaveBeenCalled();
});
it("falls back to dispatcher when same-channel origin routing fails", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- meta: {},
- });
routeReplyMock.mockResolvedValueOnce({
ok: false,
error: "outbound adapter unavailable",
});
-
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner({
- ...baseQueuedRun(" Feishu "),
- originatingChannel: "FEISHU",
- originatingTo: "ou_abc123",
- } as FollowupRun);
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: { payloads: [{ text: "hello world!" }] },
+ queued: {
+ ...baseQueuedRun(" Feishu "),
+ originatingChannel: "FEISHU",
+ originatingTo: "ou_abc123",
+ } as FollowupRun,
+ });
expect(routeReplyMock).toHaveBeenCalled();
expect(onBlockReply).toHaveBeenCalledTimes(1);
@@ -400,22 +389,17 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
it("routes followups with originating account/thread metadata", async () => {
- const onBlockReply = vi.fn(async () => {});
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "hello world!" }],
- meta: {},
+ const { onBlockReply } = await runMessagingCase({
+ agentResult: { payloads: [{ text: "hello world!" }] },
+ queued: {
+ ...baseQueuedRun("webchat"),
+ originatingChannel: "discord",
+ originatingTo: "channel:C1",
+ originatingAccountId: "work",
+ originatingThreadId: "1739142736.000100",
+ } as FollowupRun,
});
- const runner = createMessagingDedupeRunner(onBlockReply);
-
- await runner({
- ...baseQueuedRun("webchat"),
- originatingChannel: "discord",
- originatingTo: "channel:C1",
- originatingAccountId: "work",
- originatingThreadId: "1739142736.000100",
- } as FollowupRun);
-
expect(routeReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "discord",
@@ -429,44 +413,37 @@ describe("createFollowupRunner messaging tool dedupe", () => {
});
describe("createFollowupRunner typing cleanup", () => {
- it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
+ async function runTypingCase(agentResult: Record) {
const typing = createMockTypingController();
runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [{ text: "NO_REPLY" }],
meta: {},
+ ...agentResult,
});
const runner = createFollowupRunner({
- opts: { onBlockReply: vi.fn(async () => {}) },
+ opts: { onBlockReply: createAsyncReplySpy() },
typing,
typingMode: "instant",
defaultModel: "anthropic/claude-opus-4-5",
});
await runner(baseQueuedRun());
+ return typing;
+ }
+ function expectTypingCleanup(typing: ReturnType) {
expect(typing.markRunComplete).toHaveBeenCalled();
expect(typing.markDispatchIdle).toHaveBeenCalled();
+ }
+
+ it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => {
+ const typing = await runTypingCase({ payloads: [{ text: "NO_REPLY" }] });
+ expectTypingCleanup(typing);
});
it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => {
- const typing = createMockTypingController();
- runEmbeddedPiAgentMock.mockResolvedValueOnce({
- payloads: [],
- meta: {},
- });
-
- const runner = createFollowupRunner({
- opts: { onBlockReply: vi.fn(async () => {}) },
- typing,
- typingMode: "instant",
- defaultModel: "anthropic/claude-opus-4-5",
- });
-
- await runner(baseQueuedRun());
-
- expect(typing.markRunComplete).toHaveBeenCalled();
- expect(typing.markDispatchIdle).toHaveBeenCalled();
+ const typing = await runTypingCase({ payloads: [] });
+ expectTypingCleanup(typing);
});
it("calls both markRunComplete and markDispatchIdle on agent error", async () => {
@@ -482,8 +459,7 @@ describe("createFollowupRunner typing cleanup", () => {
await runner(baseQueuedRun());
- expect(typing.markRunComplete).toHaveBeenCalled();
- expect(typing.markDispatchIdle).toHaveBeenCalled();
+ expectTypingCleanup(typing);
});
it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => {
@@ -504,8 +480,7 @@ describe("createFollowupRunner typing cleanup", () => {
await runner(baseQueuedRun());
expect(onBlockReply).toHaveBeenCalled();
- expect(typing.markRunComplete).toHaveBeenCalled();
- expect(typing.markDispatchIdle).toHaveBeenCalled();
+ expectTypingCleanup(typing);
});
});
diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts
index 3129bb61cbb..7b5869a5801 100644
--- a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts
+++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts
@@ -105,6 +105,56 @@ function buildNativeResetContext(): MsgContext {
};
}
+function createContinueDirectivesResult(resetHookTriggered: boolean) {
+ return {
+ kind: "continue" as const,
+ result: {
+ commandSource: "/new",
+ command: {
+ surface: "telegram",
+ channel: "telegram",
+ channelId: "telegram",
+ ownerList: [],
+ senderIsOwner: true,
+ isAuthorizedSender: true,
+ senderId: "123",
+ abortKey: "telegram:slash:123",
+ rawBodyNormalized: "/new",
+ commandBodyNormalized: "/new",
+ from: "telegram:123",
+ to: "slash:123",
+ resetHookTriggered,
+ },
+ allowTextCommands: true,
+ skillCommands: [],
+ directives: {},
+ cleanedBody: "/new",
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ elevatedFailures: [],
+ defaultActivation: "always",
+ resolvedThinkLevel: undefined,
+ resolvedVerboseLevel: "off",
+ resolvedReasoningLevel: "off",
+ resolvedElevatedLevel: "off",
+ execOverrides: undefined,
+ blockStreamingEnabled: false,
+ blockReplyChunking: undefined,
+ resolvedBlockStreamingBreak: undefined,
+ provider: "openai",
+ model: "gpt-4o-mini",
+ modelState: {
+ resolveDefaultThinkingLevel: async () => undefined,
+ },
+ contextTokens: 0,
+ inlineStatusRequested: false,
+ directiveAck: undefined,
+ perMessageQueueMode: undefined,
+ perMessageQueueOptions: undefined,
+ },
+ };
+}
+
describe("getReplyFromConfig reset-hook fallback", () => {
beforeEach(() => {
mocks.resolveReplyDirectives.mockReset();
@@ -131,53 +181,7 @@ describe("getReplyFromConfig reset-hook fallback", () => {
bodyStripped: "",
});
- mocks.resolveReplyDirectives.mockResolvedValue({
- kind: "continue",
- result: {
- commandSource: "/new",
- command: {
- surface: "telegram",
- channel: "telegram",
- channelId: "telegram",
- ownerList: [],
- senderIsOwner: true,
- isAuthorizedSender: true,
- senderId: "123",
- abortKey: "telegram:slash:123",
- rawBodyNormalized: "/new",
- commandBodyNormalized: "/new",
- from: "telegram:123",
- to: "slash:123",
- resetHookTriggered: false,
- },
- allowTextCommands: true,
- skillCommands: [],
- directives: {},
- cleanedBody: "/new",
- elevatedEnabled: false,
- elevatedAllowed: false,
- elevatedFailures: [],
- defaultActivation: "always",
- resolvedThinkLevel: undefined,
- resolvedVerboseLevel: "off",
- resolvedReasoningLevel: "off",
- resolvedElevatedLevel: "off",
- execOverrides: undefined,
- blockStreamingEnabled: false,
- blockReplyChunking: undefined,
- resolvedBlockStreamingBreak: undefined,
- provider: "openai",
- model: "gpt-4o-mini",
- modelState: {
- resolveDefaultThinkingLevel: async () => undefined,
- },
- contextTokens: 0,
- inlineStatusRequested: false,
- directiveAck: undefined,
- perMessageQueueMode: undefined,
- perMessageQueueOptions: undefined,
- },
- });
+ mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(false));
});
it("emits reset hooks when inline actions return early without marking resetHookTriggered", async () => {
@@ -196,53 +200,7 @@ describe("getReplyFromConfig reset-hook fallback", () => {
it("does not emit fallback hooks when resetHookTriggered is already set", async () => {
mocks.handleInlineActions.mockResolvedValue({ kind: "reply", reply: undefined });
- mocks.resolveReplyDirectives.mockResolvedValue({
- kind: "continue",
- result: {
- commandSource: "/new",
- command: {
- surface: "telegram",
- channel: "telegram",
- channelId: "telegram",
- ownerList: [],
- senderIsOwner: true,
- isAuthorizedSender: true,
- senderId: "123",
- abortKey: "telegram:slash:123",
- rawBodyNormalized: "/new",
- commandBodyNormalized: "/new",
- from: "telegram:123",
- to: "slash:123",
- resetHookTriggered: true,
- },
- allowTextCommands: true,
- skillCommands: [],
- directives: {},
- cleanedBody: "/new",
- elevatedEnabled: false,
- elevatedAllowed: false,
- elevatedFailures: [],
- defaultActivation: "always",
- resolvedThinkLevel: undefined,
- resolvedVerboseLevel: "off",
- resolvedReasoningLevel: "off",
- resolvedElevatedLevel: "off",
- execOverrides: undefined,
- blockStreamingEnabled: false,
- blockReplyChunking: undefined,
- resolvedBlockStreamingBreak: undefined,
- provider: "openai",
- model: "gpt-4o-mini",
- modelState: {
- resolveDefaultThinkingLevel: async () => undefined,
- },
- contextTokens: 0,
- inlineStatusRequested: false,
- directiveAck: undefined,
- perMessageQueueMode: undefined,
- perMessageQueueOptions: undefined,
- },
- });
+ mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult(true));
await getReplyFromConfig(buildNativeResetContext(), undefined, {});
diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts
index 2eb92bc8daa..3e5ab1ae2a7 100644
--- a/src/cron/isolated-agent.mocks.ts
+++ b/src/cron/isolated-agent.mocks.ts
@@ -21,3 +21,29 @@ vi.mock("../agents/model-selection.js", async (importOriginal) => {
vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(),
}));
+
+type LooseRecord = Record;
+
+export function makeIsolatedAgentJob(overrides?: LooseRecord) {
+ return {
+ id: "test-job",
+ name: "Test Job",
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
+ sessionTarget: "isolated",
+ payload: { kind: "agentTurn", message: "test" },
+ ...overrides,
+ } as never;
+}
+
+export function makeIsolatedAgentParams(overrides?: LooseRecord) {
+ const jobOverrides =
+ overrides && "job" in overrides ? (overrides.job as LooseRecord | undefined) : undefined;
+ return {
+ cfg: {},
+ deps: {} as never,
+ job: makeIsolatedAgentJob(jobOverrides),
+ message: "test",
+ sessionKey: "cron:test",
+ ...overrides,
+ };
+}
diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
index 0665be347f0..265b89a226a 100644
--- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
+++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
@@ -28,6 +28,23 @@ async function runExplicitTelegramAnnounceTurn(params: {
});
}
+async function withTelegramAnnounceFixture(
+ run: (params: { home: string; storePath: string; deps: CliDeps }) => Promise,
+ params?: {
+ deps?: Partial;
+ sessionStore?: { lastProvider?: string; lastTo?: string };
+ },
+): Promise {
+ await withTempCronHome(async (home) => {
+ const storePath = await writeSessionStore(home, {
+ lastProvider: params?.sessionStore?.lastProvider ?? "webchat",
+ lastTo: params?.sessionStore?.lastTo ?? "",
+ });
+ const deps = createCliDeps(params?.deps);
+ await run({ home, storePath, deps });
+ });
+}
+
function expectDeliveredOk(result: Awaited>): void {
expect(result.status).toBe("ok");
expect(result.delivered).toBe(true);
@@ -36,12 +53,67 @@ function expectDeliveredOk(result: Awaited,
): Promise {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps({
- sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
- });
- mockAgentPayloads([payload]);
+ await expectStructuredTelegramFailure({
+ payload,
+ bestEffort: true,
+ expectedStatus: "ok",
+ expectDeliveryAttempted: true,
+ });
+}
+
+async function expectStructuredTelegramFailure(params: {
+ payload: Record;
+ bestEffort: boolean;
+ expectedStatus: "ok" | "error";
+ expectedErrorFragment?: string;
+ expectDeliveryAttempted?: boolean;
+}): Promise {
+ await withTelegramAnnounceFixture(
+ async ({ home, storePath, deps }) => {
+ mockAgentPayloads([params.payload]);
+ const res = await runTelegramAnnounceTurn({
+ home,
+ storePath,
+ deps,
+ delivery: {
+ mode: "announce",
+ channel: "telegram",
+ to: "123",
+ ...(params.bestEffort ? { bestEffort: true } : {}),
+ },
+ });
+
+ expect(res.status).toBe(params.expectedStatus);
+ if (params.expectedStatus === "ok") {
+ expect(res.delivered).toBe(false);
+ }
+ if (params.expectDeliveryAttempted !== undefined) {
+ expect(res.deliveryAttempted).toBe(params.expectDeliveryAttempted);
+ }
+ if (params.expectedErrorFragment) {
+ expect(res.error).toContain(params.expectedErrorFragment);
+ }
+ expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
+ expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
+ },
+ {
+ deps: {
+ sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
+ },
+ },
+ );
+}
+
+async function runAnnounceFlowResult(bestEffort: boolean) {
+ let outcome:
+ | {
+ res: Awaited>;
+ deps: CliDeps;
+ }
+ | undefined;
+ await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
+ mockAgentPayloads([{ text: "hello from cron" }]);
+ vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
const res = await runTelegramAnnounceTurn({
home,
storePath,
@@ -50,25 +122,22 @@ async function expectBestEffortTelegramNotDelivered(
mode: "announce",
channel: "telegram",
to: "123",
- bestEffort: true,
+ bestEffort,
},
});
-
- expect(res.status).toBe("ok");
- expect(res.delivered).toBe(false);
- expect(res.deliveryAttempted).toBe(true);
- expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
- expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
+ outcome = { res, deps };
});
+ if (!outcome) {
+ throw new Error("announce flow did not produce an outcome");
+ }
+ return outcome;
}
async function expectExplicitTelegramTargetAnnounce(params: {
payloads: Array>;
expectedText: string;
}): Promise {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps();
+ await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
mockAgentPayloads(params.payloads);
const res = await runExplicitTelegramAnnounceTurn({
home,
@@ -116,9 +185,7 @@ describe("runCronIsolatedAgentTurn", () => {
});
it("routes announce injection to the delivery-target session key", async () => {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps();
+ await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
mockAgentPayloads([{ text: "hello from cron" }]);
const res = await runCronIsolatedAgentTurn({
@@ -200,9 +267,7 @@ describe("runCronIsolatedAgentTurn", () => {
});
it("skips announce when messaging tool already sent to target", async () => {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps();
+ await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
mockAgentPayloads([{ text: "sent" }], {
didSendViaMessagingTool: true,
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
@@ -228,9 +293,7 @@ describe("runCronIsolatedAgentTurn", () => {
});
it("skips announce for heartbeat-only output", async () => {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps();
+ await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => {
mockAgentPayloads([{ text: "HEARTBEAT_OK" }]);
const res = await runTelegramAnnounceTurn({
home,
@@ -246,76 +309,28 @@ describe("runCronIsolatedAgentTurn", () => {
});
it("fails when structured direct delivery fails and best-effort is disabled", async () => {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps({
- sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")),
- });
- mockAgentPayloads([{ text: "hello from cron", mediaUrl: "https://example.com/img.png" }]);
- const res = await runTelegramAnnounceTurn({
- home,
- storePath,
- deps,
- delivery: { mode: "announce", channel: "telegram", to: "123" },
- });
-
- expect(res.status).toBe("error");
- expect(res.error).toContain("boom");
- expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
- expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
+ await expectStructuredTelegramFailure({
+ payload: { text: "hello from cron", mediaUrl: "https://example.com/img.png" },
+ bestEffort: false,
+ expectedStatus: "error",
+ expectedErrorFragment: "boom",
});
});
it("fails when announce delivery reports false and best-effort is disabled", async () => {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps();
- mockAgentPayloads([{ text: "hello from cron" }]);
- vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
-
- const res = await runTelegramAnnounceTurn({
- home,
- storePath,
- deps,
- delivery: {
- mode: "announce",
- channel: "telegram",
- to: "123",
- bestEffort: false,
- },
- });
-
- expect(res.status).toBe("error");
- expect(res.error).toContain("cron announce delivery failed");
- expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
- });
+ const { res, deps } = await runAnnounceFlowResult(false);
+ expect(res.status).toBe("error");
+ expect(res.error).toContain("cron announce delivery failed");
+ expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});
it("marks attempted when announce delivery reports false and best-effort is enabled", async () => {
- await withTempCronHome(async (home) => {
- const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
- const deps = createCliDeps();
- mockAgentPayloads([{ text: "hello from cron" }]);
- vi.mocked(runSubagentAnnounceFlow).mockResolvedValueOnce(false);
-
- const res = await runTelegramAnnounceTurn({
- home,
- storePath,
- deps,
- delivery: {
- mode: "announce",
- channel: "telegram",
- to: "123",
- bestEffort: true,
- },
- });
-
- expect(res.status).toBe("ok");
- expect(res.delivered).toBe(false);
- expect(res.deliveryAttempted).toBe(true);
- expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
- expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
- });
+ const { res, deps } = await runAnnounceFlowResult(true);
+ expect(res.status).toBe("ok");
+ expect(res.delivered).toBe(false);
+ expect(res.deliveryAttempted).toBe(true);
+ expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
+ expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
});
it("ignores structured direct delivery failures when best-effort is enabled", async () => {
diff --git a/src/cron/isolated-agent.subagent-model.test.ts b/src/cron/isolated-agent.subagent-model.test.ts
index eb8d2732a68..ea651f5d8a3 100644
--- a/src/cron/isolated-agent.subagent-model.test.ts
+++ b/src/cron/isolated-agent.subagent-model.test.ts
@@ -1,23 +1,14 @@
+import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
-import type { CliDeps } from "../cli/deps.js";
-import type { OpenClawConfig } from "../config/config.js";
-import type { CronJob } from "./types.js";
-
-vi.mock("../agents/pi-embedded.js", () => ({
- abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
- runEmbeddedPiAgent: vi.fn(),
- resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
-}));
-vi.mock("../agents/model-catalog.js", () => ({
- loadModelCatalog: vi.fn(),
-}));
-
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
+import type { CliDeps } from "../cli/deps.js";
+import type { OpenClawConfig } from "../config/config.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
+import type { CronJob } from "./types.js";
async function withTempHome(fn: (home: string) => Promise): Promise {
return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" });
@@ -100,50 +91,93 @@ function mockEmbeddedAgent() {
});
}
+async function runSubagentModelCase(params: {
+ home: string;
+ cfgOverrides?: Partial;
+ jobModelOverride?: string;
+}) {
+ const storePath = await writeSessionStore(params.home);
+ mockEmbeddedAgent();
+ const job = makeJob();
+ if (params.jobModelOverride) {
+ job.payload = { kind: "agentTurn", message: "do work", model: params.jobModelOverride };
+ }
+
+ await runCronIsolatedAgentTurn({
+ cfg: makeCfg(params.home, storePath, params.cfgOverrides),
+ deps: makeDeps(),
+ job,
+ message: "do work",
+ sessionKey: "cron:job-sub",
+ lane: "cron",
+ });
+
+ return vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
+}
+
describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => {
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue([]);
});
- it("uses agents.defaults.subagents.model when set", async () => {
- await withTempHome(async (home) => {
- const storePath = await writeSessionStore(home);
- mockEmbeddedAgent();
-
- await runCronIsolatedAgentTurn({
- cfg: makeCfg(home, storePath, {
- agents: {
- defaults: {
- model: "anthropic/claude-sonnet-4-5",
- workspace: path.join(home, "openclaw"),
- subagents: { model: "ollama/llama3.2:3b" },
- },
+ it.each([
+ {
+ name: "uses agents.defaults.subagents.model when set",
+ cfgOverrides: {
+ agents: {
+ defaults: {
+ model: "anthropic/claude-sonnet-4-5",
+ subagents: { model: "ollama/llama3.2:3b" },
},
- }),
- deps: makeDeps(),
- job: makeJob(),
- message: "do work",
- sessionKey: "cron:job-sub",
- lane: "cron",
- });
-
- const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
- expect(call?.provider).toBe("ollama");
- expect(call?.model).toBe("llama3.2:3b");
+ },
+ } satisfies Partial,
+ expectedProvider: "ollama",
+ expectedModel: "llama3.2:3b",
+ },
+ {
+ name: "falls back to main model when subagents.model is unset",
+ cfgOverrides: undefined,
+ expectedProvider: "anthropic",
+ expectedModel: "claude-sonnet-4-5",
+ },
+ {
+ name: "supports subagents.model with {primary} object format",
+ cfgOverrides: {
+ agents: {
+ defaults: {
+ model: "anthropic/claude-sonnet-4-5",
+ subagents: { model: { primary: "google/gemini-2.5-flash" } },
+ },
+ },
+ } satisfies Partial,
+ expectedProvider: "google",
+ expectedModel: "gemini-2.5-flash",
+ },
+ ])("$name", async ({ cfgOverrides, expectedProvider, expectedModel }) => {
+ await withTempHome(async (home) => {
+ const resolvedCfg =
+ cfgOverrides === undefined
+ ? undefined
+ : ({
+ agents: {
+ defaults: {
+ ...cfgOverrides.agents?.defaults,
+ workspace: path.join(home, "openclaw"),
+ },
+ },
+ } satisfies Partial);
+ const call = await runSubagentModelCase({ home, cfgOverrides: resolvedCfg });
+ expect(call?.provider).toBe(expectedProvider);
+ expect(call?.model).toBe(expectedModel);
});
});
it("explicit job model override takes precedence over subagents.model", async () => {
await withTempHome(async (home) => {
- const storePath = await writeSessionStore(home);
- mockEmbeddedAgent();
-
- const job = makeJob();
- job.payload = { kind: "agentTurn", message: "do work", model: "openai/gpt-4o" };
-
- await runCronIsolatedAgentTurn({
- cfg: makeCfg(home, storePath, {
+ const call = await runSubagentModelCase({
+ home,
+ cfgOverrides: {
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-5",
@@ -151,65 +185,11 @@ describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => {
subagents: { model: "ollama/llama3.2:3b" },
},
},
- }),
- deps: makeDeps(),
- job,
- message: "do work",
- sessionKey: "cron:job-sub",
- lane: "cron",
+ },
+ jobModelOverride: "openai/gpt-4o",
});
-
- const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.provider).toBe("openai");
expect(call?.model).toBe("gpt-4o");
});
});
-
- it("falls back to main model when subagents.model is unset", async () => {
- await withTempHome(async (home) => {
- const storePath = await writeSessionStore(home);
- mockEmbeddedAgent();
-
- await runCronIsolatedAgentTurn({
- cfg: makeCfg(home, storePath),
- deps: makeDeps(),
- job: makeJob(),
- message: "do work",
- sessionKey: "cron:job-sub",
- lane: "cron",
- });
-
- const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
- expect(call?.provider).toBe("anthropic");
- expect(call?.model).toBe("claude-sonnet-4-5");
- });
- });
-
- it("supports subagents.model with {primary} object format", async () => {
- await withTempHome(async (home) => {
- const storePath = await writeSessionStore(home);
- mockEmbeddedAgent();
-
- await runCronIsolatedAgentTurn({
- cfg: makeCfg(home, storePath, {
- agents: {
- defaults: {
- model: "anthropic/claude-sonnet-4-5",
- workspace: path.join(home, "openclaw"),
- subagents: { model: { primary: "google/gemini-2.5-flash" } },
- },
- },
- }),
- deps: makeDeps(),
- job: makeJob(),
- message: "do work",
- sessionKey: "cron:job-sub",
- lane: "cron",
- });
-
- const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
- expect(call?.provider).toBe("google");
- expect(call?.model).toBe("gemini-2.5-flash");
- });
- });
});
diff --git a/src/cron/isolated-agent/run.cron-model-override.test.ts b/src/cron/isolated-agent/run.cron-model-override.test.ts
index 796606e4b83..eb8f9eae79d 100644
--- a/src/cron/isolated-agent/run.cron-model-override.test.ts
+++ b/src/cron/isolated-agent/run.cron-model-override.test.ts
@@ -1,183 +1,21 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { runWithModelFallback } from "../../agents/model-fallback.js";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ clearFastTestEnv,
+ loadRunCronIsolatedAgentTurn,
+ logWarnMock,
+ makeCronSession,
+ makeCronSessionEntry,
+ resolveAgentConfigMock,
+ resolveAllowedModelRefMock,
+ resolveConfiguredModelRefMock,
+ resolveCronSessionMock,
+ resetRunCronIsolatedAgentTurnHarness,
+ restoreFastTestEnv,
+ runWithModelFallbackMock,
+ updateSessionStoreMock,
+} from "./run.test-harness.js";
-// ---------- mocks ----------
-
-const resolveAgentConfigMock = vi.fn();
-
-vi.mock("../../agents/agent-scope.js", () => ({
- resolveAgentConfig: resolveAgentConfigMock,
- resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
- resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined),
- resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
- resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
- resolveAgentSkillsFilter: vi.fn().mockReturnValue(undefined),
-}));
-
-vi.mock("../../agents/skills.js", () => ({
- buildWorkspaceSkillSnapshot: vi.fn().mockReturnValue({
- prompt: "",
- resolvedSkills: [],
- version: 42,
- }),
-}));
-
-vi.mock("../../agents/skills/refresh.js", () => ({
- getSkillsSnapshotVersion: vi.fn().mockReturnValue(42),
-}));
-
-vi.mock("../../agents/workspace.js", () => ({
- ensureAgentWorkspace: vi.fn().mockResolvedValue({ dir: "/tmp/workspace" }),
-}));
-
-vi.mock("../../agents/model-catalog.js", () => ({
- loadModelCatalog: vi.fn().mockResolvedValue({ models: [] }),
-}));
-
-const resolveAllowedModelRefMock = vi.fn();
-const resolveConfiguredModelRefMock = vi.fn();
-
-vi.mock("../../agents/model-selection.js", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
- isCliProvider: vi.fn().mockReturnValue(false),
- resolveAllowedModelRef: resolveAllowedModelRefMock,
- resolveConfiguredModelRef: resolveConfiguredModelRefMock,
- resolveHooksGmailModel: vi.fn().mockReturnValue(null),
- resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
- };
-});
-
-vi.mock("../../agents/model-fallback.js", () => ({
- runWithModelFallback: vi.fn(),
-}));
-
-const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
-
-vi.mock("../../agents/pi-embedded.js", () => ({
- runEmbeddedPiAgent: vi.fn(),
-}));
-
-vi.mock("../../agents/context.js", () => ({
- lookupContextTokens: vi.fn().mockReturnValue(128000),
-}));
-
-vi.mock("../../agents/date-time.js", () => ({
- formatUserTime: vi.fn().mockReturnValue("2026-02-10 12:00"),
- resolveUserTimeFormat: vi.fn().mockReturnValue("24h"),
- resolveUserTimezone: vi.fn().mockReturnValue("UTC"),
-}));
-
-vi.mock("../../agents/timeout.js", () => ({
- resolveAgentTimeoutMs: vi.fn().mockReturnValue(60_000),
-}));
-
-vi.mock("../../agents/usage.js", () => ({
- deriveSessionTotalTokens: vi.fn().mockReturnValue(30),
- hasNonzeroUsage: vi.fn().mockReturnValue(false),
-}));
-
-vi.mock("../../agents/subagent-announce.js", () => ({
- runSubagentAnnounceFlow: vi.fn().mockResolvedValue(true),
-}));
-
-vi.mock("../../agents/cli-runner.js", () => ({
- runCliAgent: vi.fn(),
-}));
-
-vi.mock("../../agents/cli-session.js", () => ({
- getCliSessionId: vi.fn().mockReturnValue(undefined),
- setCliSessionId: vi.fn(),
-}));
-
-vi.mock("../../auto-reply/thinking.js", () => ({
- normalizeThinkLevel: vi.fn().mockReturnValue(undefined),
- normalizeVerboseLevel: vi.fn().mockReturnValue("off"),
- supportsXHighThinking: vi.fn().mockReturnValue(false),
-}));
-
-vi.mock("../../cli/outbound-send-deps.js", () => ({
- createOutboundSendDeps: vi.fn().mockReturnValue({}),
-}));
-
-const updateSessionStoreMock = vi.fn().mockResolvedValue(undefined);
-
-vi.mock("../../config/sessions.js", () => ({
- resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
- resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
- setSessionRuntimeModel: vi.fn(),
- updateSessionStore: updateSessionStoreMock,
-}));
-
-vi.mock("../../routing/session-key.js", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- buildAgentMainSessionKey: vi.fn().mockReturnValue("agent:default:cron:test"),
- normalizeAgentId: vi.fn((id: string) => id),
- };
-});
-
-vi.mock("../../infra/agent-events.js", () => ({
- registerAgentRunContext: vi.fn(),
-}));
-
-vi.mock("../../infra/outbound/deliver.js", () => ({
- deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
-}));
-
-vi.mock("../../infra/skills-remote.js", () => ({
- getRemoteSkillEligibility: vi.fn().mockReturnValue({}),
-}));
-
-const logWarnMock = vi.fn();
-vi.mock("../../logger.js", () => ({
- logWarn: logWarnMock,
-}));
-
-vi.mock("../../security/external-content.js", () => ({
- buildSafeExternalPrompt: vi.fn().mockReturnValue("safe prompt"),
- detectSuspiciousPatterns: vi.fn().mockReturnValue([]),
- getHookType: vi.fn().mockReturnValue("unknown"),
- isExternalHookSession: vi.fn().mockReturnValue(false),
-}));
-
-vi.mock("../delivery.js", () => ({
- resolveCronDeliveryPlan: vi.fn().mockReturnValue({ requested: false }),
-}));
-
-vi.mock("./delivery-target.js", () => ({
- resolveDeliveryTarget: vi.fn().mockResolvedValue({
- channel: "discord",
- to: undefined,
- accountId: undefined,
- error: undefined,
- }),
-}));
-
-vi.mock("./helpers.js", () => ({
- isHeartbeatOnlyResponse: vi.fn().mockReturnValue(false),
- pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
- pickLastNonEmptyTextFromPayloads: vi.fn().mockReturnValue("test output"),
- pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
- pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
- resolveHeartbeatAckMaxChars: vi.fn().mockReturnValue(100),
-}));
-
-const resolveCronSessionMock = vi.fn();
-vi.mock("./session.js", () => ({
- resolveCronSession: resolveCronSessionMock,
-}));
-
-vi.mock("../../agents/defaults.js", () => ({
- DEFAULT_CONTEXT_TOKENS: 128000,
- DEFAULT_MODEL: "gpt-4",
- DEFAULT_PROVIDER: "openai",
-}));
-
-const { runCronIsolatedAgentTurn } = await import("./run.js");
+const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
// ---------- helpers ----------
@@ -209,10 +47,7 @@ function makeParams(overrides?: Record) {
function makeFreshSessionEntry(overrides?: Record) {
return {
- sessionId: "test-session-id",
- updatedAt: 0,
- systemSent: false,
- skillsSnapshot: undefined,
+ ...makeCronSessionEntry(),
// Crucially: no model or modelProvider — simulates a brand-new session
model: undefined as string | undefined,
modelProvider: undefined as string | undefined,
@@ -249,9 +84,8 @@ describe("runCronIsolatedAgentTurn — cron model override (#21057)", () => {
let cronSession: { sessionEntry: ReturnType; [k: string]: unknown };
beforeEach(() => {
- vi.clearAllMocks();
- previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
- delete process.env.OPENCLAW_TEST_FAST;
+ previousFastTestEnv = clearFastTestEnv();
+ resetRunCronIsolatedAgentTurnHarness();
// Agent default model is Opus
resolveConfiguredModelRefMock.mockReturnValue({
@@ -267,22 +101,14 @@ describe("runCronIsolatedAgentTurn — cron model override (#21057)", () => {
resolveAgentConfigMock.mockReturnValue(undefined);
updateSessionStoreMock.mockResolvedValue(undefined);
- cronSession = {
- storePath: "/tmp/store.json",
- store: {},
+ cronSession = makeCronSession({
sessionEntry: makeFreshSessionEntry(),
- systemSent: false,
- isNewSession: true,
- };
+ }) as { sessionEntry: ReturnType