test(agents): dedupe agent and cron test scaffolds

This commit is contained in:
Peter Steinberger
2026-03-02 06:40:42 +00:00
parent 281494ae52
commit 7e29d604ba
38 changed files with 3114 additions and 4486 deletions

View File

@@ -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<typeof import("../config/config.js")>();
return {
@@ -71,20 +96,21 @@ vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) =
await importOriginal<typeof import("../infra/outbound/session-binding-service.js")>();
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>): SessionBindingRecord {
return {
bindingId: "default:child-thread",
@@ -106,27 +132,21 @@ function createSessionBinding(overrides?: Partial<SessionBindingRecord>): 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<string, unknown> })

View File

@@ -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<typeof piCodingAgent>();
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<ExtensionContext["model"]>;
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.");
});
});

View File

@@ -13,6 +13,7 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
});
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
type SummarizeInStagesInput = Parameters<typeof summarizeInStages>[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<ExtensionContext["model"]>;
const summarizeBase: Omit<SummarizeInStagesInput, "messages"> = {
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<Omit<SummarizeInStagesInput, "messages">> = {},
) {
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.",

View File

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

View File

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

View File

@@ -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<void>) {
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<void> {
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<string, { apiKey?: string; baseUrl?: string }>;
}>();
}
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<string, { baseUrl?: string }>;
};
}>();
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<string, { apiKey?: string; baseUrl?: string }>;
}>();
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<string, { apiKey?: string; baseUrl?: string }>;
}>();
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;
}
}
});
});
});
});

View File

@@ -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<string, { models?: ModelEntry[] }>;
};
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<void>) {
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<ModelEntry | undefined> {
await ensureOpenClawModelsJson(cfg);
const parsed = await readGeneratedModelsJson<ModelsJson>();
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;
}
}
});
});
});
});

View File

@@ -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<string, unknown>) {
return getNodesTool().execute("call1", input as never);
}
type NodesToolResult = Awaited<ReturnType<typeof executeNodes>>;
type GatewayMockResult = Record<string, unknown> | 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<GatewayMockResult>;
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<GatewayMockResult>;
onApprovalRequest?: (approvalParams: unknown) => GatewayMockResult | Promise<GatewayMockResult>;
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",

View File

@@ -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<string, unknown> };
async function getGatewayCalls(): Promise<GatewayCall[]> {
const { callGateway } = await import("../gateway/call.js");
return (callGateway as unknown as ReturnType<typeof vi.fn>).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<string, unknown>;
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",
});
});
});

View File

@@ -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<string, unknown> };
async function getGatewayCalls(): Promise<GatewayCall[]> {
const { callGateway } = await import("../gateway/call.js");
return (callGateway as unknown as ReturnType<typeof vi.fn>).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);
});
});

View File

@@ -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<string, unknown> };
async function getGatewayCalls(): Promise<GatewayCall[]> {
const { callGateway } = await import("../gateway/call.js");
return (callGateway as unknown as ReturnType<typeof vi.fn>).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<string, unknown>) {
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);
});
});

View File

@@ -320,54 +320,55 @@ describe("downgradeOpenAIReasoningBlocks", () => {
});
describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
const callIdWithReasoning = "call_123|fc_123";
const callIdWithoutReasoning = "call_123";
const readArgs = {} as Record<string, never>;
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"),
]);
});
});

View File

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

View File

@@ -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<typeof makeUsage>;
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<Parameters<SanitizeSessionHistoryFn>[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([]);

View File

@@ -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<string>,
) {
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<typeof wrappedFn>
>;
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<typeof wrappedFn>
>;
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<typeof wrappedFn>
>;
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);

View File

@@ -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>): SandboxContext {
return createSandboxTestContext({
overrides: {
@@ -58,38 +66,37 @@ async function withTempDir<T>(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);

View File

@@ -239,6 +239,28 @@ describe("sanitizeToolUseResultPairing", () => {
});
describe("sanitizeToolCallInputs", () => {
function sanitizeAssistantContent(
content: unknown[],
options?: Parameters<typeof sanitizeToolCallInputs>[1],
) {
return sanitizeToolCallInputs(
[
{
role: "assistant",
content,
},
] as unknown as AgentMessage[],
options,
);
}
function sanitizeAssistantToolCalls(
content: unknown[],
options?: Parameters<typeof sanitizeToolCallInputs>[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");

View File

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

View File

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

View File

@@ -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<typeof mod.registerSubagentRun>[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<Pick<RegisterSubagentRunInput, "spawnMode">> = {},
): 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<RegisterSubagentRunInput, "spawnMode" | "requesterOrigin" | "expectsCompletionMessage">
>,
): 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);

View File

@@ -40,6 +40,58 @@ function getActionEnum(properties: Record<string, unknown>) {
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<string, unknown>;
toolOptions?: Partial<Parameters<typeof createMessageTool>[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<string, unknown>;
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<string, unknown> } };
}
)?.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<string, unknown> } };
}
)?.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 <think> 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: "<think>internal reasoning</think>Hello!",
expected: "Hello!",
target: "signal:+15551234567",
text: "<think>internal reasoning</think>Hello!",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.params?.text).toBe("Hello!");
});
it("strips <think> 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: "<think>reasoning here</think>Reply text",
expected: "Reply text",
target: "discord:123",
content: "<think>reasoning here</think>Reply 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");
});
});

View File

@@ -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<ReturnType<ReturnType<typeof createSessionsListTool>["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<T>(
name: string,
run: (stateDir: string) => Promise<T>,
): Promise<T> {
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",

View File

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

View File

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

View File

@@ -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<typeof createCfg>[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");
});
});

View File

@@ -52,6 +52,22 @@ const hoisted = vi.hoisted(() => {
};
});
function createAcpCommandSessionBindingService() {
const forward =
<A extends unknown[], T>(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<typeof import("../../infra/outbound/session-binding-service.js")>();
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<typeof createAcpCommandSessionBindingService>;
};
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<string, unknown>;
};
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<string, unknown>;
}) =>
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:");

View File

@@ -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<typeof import("../../infra/outbound/session-binding-service.js")>();
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<string> {
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");
});

View File

@@ -26,21 +26,25 @@ function createDispatcher(): ReplyDispatcher {
};
}
function createCoordinator(onReplyStart?: (...args: unknown[]) => Promise<void>) {
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", {});

View File

@@ -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<void>,
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<void> }) => {
await emitToolLifecycleEvents(onEvent, toolCallId);
},
);
}
function mockVisibleTextTurn(text = "visible") {
managerMocks.runTurn.mockImplementationOnce(
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
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<void> }) => {
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<void> }) => {
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<void> }) => {
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<void> }) => {
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();

View File

@@ -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<string, unknown>;
queued?: FollowupRun;
runnerOverrides?: Partial<{
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
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<string, unknown>) {
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<string, SessionEntry> = { [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<string, unknown>) {
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<typeof createMockTypingController>) {
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);
});
});

View File

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

View File

@@ -21,3 +21,29 @@ vi.mock("../agents/model-selection.js", async (importOriginal) => {
vi.mock("../agents/subagent-announce.js", () => ({
runSubagentAnnounceFlow: vi.fn(),
}));
type LooseRecord = Record<string, unknown>;
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,
};
}

View File

@@ -28,6 +28,23 @@ async function runExplicitTelegramAnnounceTurn(params: {
});
}
async function withTelegramAnnounceFixture(
run: (params: { home: string; storePath: string; deps: CliDeps }) => Promise<void>,
params?: {
deps?: Partial<CliDeps>;
sessionStore?: { lastProvider?: string; lastTo?: string };
},
): Promise<void> {
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<ReturnType<typeof runCronIsolatedAgentTurn>>): void {
expect(result.status).toBe("ok");
expect(result.delivered).toBe(true);
@@ -36,12 +53,67 @@ function expectDeliveredOk(result: Awaited<ReturnType<typeof runCronIsolatedAgen
async function expectBestEffortTelegramNotDelivered(
payload: Record<string, unknown>,
): Promise<void> {
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<string, unknown>;
bestEffort: boolean;
expectedStatus: "ok" | "error";
expectedErrorFragment?: string;
expectDeliveryAttempted?: boolean;
}): Promise<void> {
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<ReturnType<typeof runCronIsolatedAgentTurn>>;
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<Record<string, unknown>>;
expectedText: string;
}): Promise<void> {
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 () => {

View File

@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" });
@@ -100,50 +91,93 @@ function mockEmbeddedAgent() {
});
}
async function runSubagentModelCase(params: {
home: string;
cfgOverrides?: Partial<OpenClawConfig>;
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<OpenClawConfig>,
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<OpenClawConfig>,
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<OpenClawConfig>);
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");
});
});
});

View File

@@ -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: "<available_skills></available_skills>",
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<typeof import("../../agents/model-selection.js")>();
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<typeof import("../../routing/session-key.js")>();
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<string, unknown>) {
function makeFreshSessionEntry(overrides?: Record<string, unknown>) {
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<typeof makeFreshSessionEntry>; [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<typeof makeFreshSessionEntry>; [k: string]: unknown };
resolveCronSessionMock.mockReturnValue(cronSession);
});
afterEach(() => {
if (previousFastTestEnv == null) {
delete process.env.OPENCLAW_TEST_FAST;
return;
}
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
restoreFastTestEnv(previousFastTestEnv);
});
it("persists cron payload model on session entry even when the run throws", async () => {

View File

@@ -1,193 +1,18 @@
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,
makeCronSession,
resolveAgentModelFallbacksOverrideMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runWithModelFallbackMock,
} from "./run.test-harness.js";
// ---------- mocks (same pattern as run.skill-filter.test.ts) ----------
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
const resolveAgentModelFallbacksOverrideMock = vi.fn();
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(),
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
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: "<available_skills></available_skills>",
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: [] }),
}));
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
return {
...actual,
getModelRefStatus: vi.fn().mockReturnValue({ allowed: false }),
isCliProvider: vi.fn().mockReturnValue(false),
resolveAllowedModelRef: vi
.fn()
.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } }),
resolveConfiguredModelRef: vi.fn().mockReturnValue({ provider: "openai", model: "gpt-4" }),
resolveHooksGmailModel: vi.fn().mockReturnValue(null),
resolveThinkingDefault: vi.fn().mockReturnValue(undefined),
};
});
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: vi.fn().mockResolvedValue({
result: {
payloads: [{ text: "test output" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
},
provider: "openai",
model: "gpt-4",
}),
}));
const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
payloads: [{ text: "test output" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
}),
}));
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({}),
}));
vi.mock("../../config/sessions.js", () => ({
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
setSessionRuntimeModel: vi.fn(),
updateSessionStore: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../routing/session-key.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
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({}),
}));
vi.mock("../../logger.js", () => ({
logWarn: vi.fn(),
}));
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");
// ---------- helpers ----------
function makeJob(overrides?: Record<string, unknown>) {
function makePayloadJob(overrides?: Record<string, unknown>) {
return {
id: "test-job",
name: "Test Job",
@@ -198,11 +23,11 @@ function makeJob(overrides?: Record<string, unknown>) {
} as never;
}
function makeParams(overrides?: Record<string, unknown>) {
function makePayloadParams(overrides?: Record<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makeJob(overrides?.job ? (overrides.job as Record<string, unknown>) : undefined),
job: makePayloadJob(overrides?.job as Record<string, unknown> | undefined),
message: "test",
sessionKey: "cron:test",
...overrides,
@@ -215,80 +40,50 @@ describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
delete process.env.OPENCLAW_TEST_FAST;
resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined);
resolveCronSessionMock.mockReturnValue({
storePath: "/tmp/store.json",
store: {},
sessionEntry: {
sessionId: "test-session-id",
updatedAt: 0,
systemSent: false,
skillsSnapshot: undefined,
},
systemSent: false,
isNewSession: true,
});
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
if (previousFastTestEnv == null) {
delete process.env.OPENCLAW_TEST_FAST;
return;
restoreFastTestEnv(previousFastTestEnv);
});
it.each([
{
name: "passes payload.fallbacks as fallbacksOverride when defined",
payload: {
kind: "agentTurn",
message: "test",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
},
expectedFallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
},
{
name: "falls back to agent-level fallbacks when payload.fallbacks is undefined",
payload: { kind: "agentTurn", message: "test" },
agentFallbacks: ["openai/gpt-4o"],
expectedFallbacks: ["openai/gpt-4o"],
},
{
name: "payload.fallbacks=[] disables fallbacks even when agent config has them",
payload: { kind: "agentTurn", message: "test", fallbacks: [] },
agentFallbacks: ["openai/gpt-4o"],
expectedFallbacks: [],
},
])("$name", async ({ payload, agentFallbacks, expectedFallbacks }) => {
if (agentFallbacks) {
resolveAgentModelFallbacksOverrideMock.mockReturnValue(agentFallbacks);
}
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
});
it("passes payload.fallbacks as fallbacksOverride when defined", async () => {
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({
payload: {
kind: "agentTurn",
message: "test",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5"],
},
}),
makePayloadParams({
job: makePayloadJob({ payload }),
}),
);
expect(result.status).toBe("ok");
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([
"anthropic/claude-sonnet-4-6",
"openai/gpt-5",
]);
});
it("falls back to agent-level fallbacks when payload.fallbacks is undefined", async () => {
resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({ payload: { kind: "agentTurn", message: "test" } }),
}),
);
expect(result.status).toBe("ok");
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(["openai/gpt-4o"]);
});
it("payload.fallbacks=[] disables fallbacks even when agent config has them", async () => {
resolveAgentModelFallbacksOverrideMock.mockReturnValue(["openai/gpt-4o"]);
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({
payload: { kind: "agentTurn", message: "test", fallbacks: [] },
}),
}),
);
expect(result.status).toBe("ok");
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual([]);
expect(runWithModelFallbackMock.mock.calls[0][0].fallbacksOverride).toEqual(expectedFallbacks);
});
});

View File

@@ -1,198 +1,25 @@
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 {
buildWorkspaceSkillSnapshotMock,
clearFastTestEnv,
getCliSessionIdMock,
isCliProviderMock,
loadRunCronIsolatedAgentTurn,
logWarnMock,
makeCronSession,
resolveAgentConfigMock,
resolveAgentSkillsFilterMock,
resolveAllowedModelRefMock,
resolveCronSessionMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runCliAgentMock,
runWithModelFallbackMock,
} from "./run.test-harness.js";
// ---------- mocks ----------
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
const buildWorkspaceSkillSnapshotMock = vi.fn();
const resolveAgentConfigMock = vi.fn();
const resolveAgentSkillsFilterMock = vi.fn();
const getModelRefStatusMock = vi.fn().mockReturnValue({ allowed: false });
const isCliProviderMock = vi.fn().mockReturnValue(false);
const resolveAllowedModelRefMock = vi.fn();
const resolveConfiguredModelRefMock = vi.fn();
const resolveHooksGmailModelMock = vi.fn();
const resolveThinkingDefaultMock = vi.fn();
const logWarnMock = 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: resolveAgentSkillsFilterMock,
}));
vi.mock("../../agents/skills.js", () => ({
buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock,
}));
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: [] }),
}));
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
return {
...actual,
getModelRefStatus: getModelRefStatusMock,
isCliProvider: isCliProviderMock,
resolveAllowedModelRef: resolveAllowedModelRefMock,
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
resolveHooksGmailModel: resolveHooksGmailModelMock,
resolveThinkingDefault: resolveThinkingDefaultMock,
};
});
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: vi.fn().mockResolvedValue({
result: {
payloads: [{ text: "test output" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
},
provider: "openai",
model: "gpt-4",
}),
}));
const runWithModelFallbackMock = vi.mocked(runWithModelFallback);
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
payloads: [{ text: "test output" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
}),
}));
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),
}));
const runCliAgentMock = vi.fn();
vi.mock("../../agents/cli-runner.js", () => ({
runCliAgent: runCliAgentMock,
}));
const getCliSessionIdMock = vi.fn().mockReturnValue(undefined);
vi.mock("../../agents/cli-session.js", () => ({
getCliSessionId: getCliSessionIdMock,
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({}),
}));
vi.mock("../../config/sessions.js", () => ({
resolveAgentMainSessionKey: vi.fn().mockReturnValue("main:default"),
resolveSessionTranscriptPath: vi.fn().mockReturnValue("/tmp/transcript.jsonl"),
setSessionRuntimeModel: vi.fn(),
updateSessionStore: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../routing/session-key.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../routing/session-key.js")>();
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({}),
}));
vi.mock("../../logger.js", () => ({
logWarn: (...args: unknown[]) => logWarnMock(...args),
}));
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");
// ---------- helpers ----------
function makeJob(overrides?: Record<string, unknown>) {
function makeSkillJob(overrides?: Record<string, unknown>) {
return {
id: "test-job",
name: "Test Job",
@@ -203,11 +30,11 @@ function makeJob(overrides?: Record<string, unknown>) {
} as never;
}
function makeParams(overrides?: Record<string, unknown>) {
function makeSkillParams(overrides?: Record<string, unknown>) {
return {
cfg: {},
deps: {} as never,
job: makeJob(),
job: makeSkillJob(overrides?.job as Record<string, unknown> | undefined),
message: "test",
sessionKey: "cron:test",
...overrides,
@@ -219,57 +46,45 @@ function makeParams(overrides?: Record<string, unknown>) {
describe("runCronIsolatedAgentTurn — skill filter", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
delete process.env.OPENCLAW_TEST_FAST;
buildWorkspaceSkillSnapshotMock.mockReturnValue({
prompt: "<available_skills></available_skills>",
resolvedSkills: [],
version: 42,
});
resolveAgentConfigMock.mockReturnValue(undefined);
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" });
resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } });
resolveHooksGmailModelMock.mockReturnValue(null);
resolveThinkingDefaultMock.mockReturnValue(undefined);
getModelRefStatusMock.mockReturnValue({ allowed: false });
isCliProviderMock.mockReturnValue(false);
logWarnMock.mockReset();
// Fresh session object per test — prevents mutation leaking between tests
resolveCronSessionMock.mockReturnValue({
storePath: "/tmp/store.json",
store: {},
sessionEntry: {
sessionId: "test-session-id",
updatedAt: 0,
systemSent: false,
skillsSnapshot: undefined,
},
systemSent: false,
isNewSession: true,
});
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
resolveCronSessionMock.mockReturnValue(makeCronSession());
});
afterEach(() => {
if (previousFastTestEnv == null) {
delete process.env.OPENCLAW_TEST_FAST;
return;
}
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
restoreFastTestEnv(previousFastTestEnv);
});
async function runSkillFilterCase(overrides?: Record<string, unknown>) {
const result = await runCronIsolatedAgentTurn(makeSkillParams(overrides));
expect(result.status).toBe("ok");
return result;
}
function expectDefaultModelCall(params: { primary: string; fallbacks: string[] }) {
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
const model = callCfg?.agents?.defaults?.model as { primary?: string; fallbacks?: string[] };
expect(model?.primary).toBe(params.primary);
expect(model?.fallbacks).toEqual(params.fallbacks);
}
function mockCliFallbackInvocation() {
runWithModelFallbackMock.mockImplementationOnce(
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
const result = await params.run("claude-cli", "claude-opus-4-6");
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
},
);
}
it("passes agent-level skillFilter to buildWorkspaceSkillSnapshot", async () => {
resolveAgentSkillsFilterMock.mockReturnValue(["meme-factory", "weather"]);
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } },
agentId: "scout",
}),
);
expect(result.status).toBe("ok");
await runSkillFilterCase({
cfg: { agents: { list: [{ id: "scout", skills: ["meme-factory", "weather"] }] } },
agentId: "scout",
});
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [
"meme-factory",
@@ -280,14 +95,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
it("omits skillFilter when agent has no skills config", async () => {
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: { agents: { list: [{ id: "general" }] } },
agentId: "general",
}),
);
expect(result.status).toBe("ok");
await runSkillFilterCase({
cfg: { agents: { list: [{ id: "general" }] } },
agentId: "general",
});
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
// When no skills config, skillFilter should be undefined (no filtering applied)
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1].skillFilter).toBeUndefined();
@@ -296,14 +107,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
it("passes empty skillFilter when agent explicitly disables all skills", async () => {
resolveAgentSkillsFilterMock.mockReturnValue([]);
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: { agents: { list: [{ id: "silent", skills: [] }] } },
agentId: "silent",
}),
);
expect(result.status).toBe("ok");
await runSkillFilterCase({
cfg: { agents: { list: [{ id: "silent", skills: [] }] } },
agentId: "silent",
});
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
// Explicit empty skills list should forward [] to filter out all skills
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", []);
@@ -328,14 +135,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
isNewSession: true,
});
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } },
agentId: "weather-bot",
}),
);
expect(result.status).toBe("ok");
await runSkillFilterCase({
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } },
agentId: "weather-bot",
});
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce();
expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [
"weather",
@@ -343,9 +146,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
it("forces a fresh session for isolated cron runs", async () => {
const result = await runCronIsolatedAgentTurn(makeParams());
expect(result.status).toBe("ok");
await runSkillFilterCase();
expect(resolveCronSessionMock).toHaveBeenCalledOnce();
expect(resolveCronSessionMock.mock.calls[0]?.[0]).toMatchObject({
forceNew: true,
@@ -372,14 +173,10 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
isNewSession: true,
});
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } },
agentId: "weather-bot",
}),
);
expect(result.status).toBe("ok");
await runSkillFilterCase({
cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } },
agentId: "weather-bot",
});
expect(buildWorkspaceSkillSnapshotMock).not.toHaveBeenCalled();
});
@@ -392,27 +189,21 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
async function expectPrimaryOverridePreservesDefaults(modelOverride: unknown) {
resolveAgentConfigMock.mockReturnValue({ model: modelOverride });
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
},
await runSkillFilterCase({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
},
},
agentId: "scout",
}),
);
},
agentId: "scout",
});
expect(result.status).toBe("ok");
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
const model = callCfg?.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
expect(model?.primary).toBe("anthropic/claude-sonnet-4-5");
expect(model?.fallbacks).toEqual(defaultFallbacks);
expectDefaultModelCall({
primary: "anthropic/claude-sonnet-4-5",
fallbacks: defaultFallbacks,
});
}
it("preserves defaults when agent overrides primary as string", async () => {
@@ -429,8 +220,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({
makeSkillParams({
job: makeSkillJob({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
}),
}),
@@ -449,32 +240,25 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
error: "model not allowed: anthropic/claude-sonnet-4-6",
});
const result = await runCronIsolatedAgentTurn(
makeParams({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
},
await runSkillFilterCase({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.3-codex", fallbacks: defaultFallbacks },
},
},
job: makeJob({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
}),
},
job: makeSkillJob({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
}),
);
expect(result.status).toBe("ok");
});
expect(logWarnMock).toHaveBeenCalledWith(
"cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults",
);
expect(runWithModelFallbackMock).toHaveBeenCalledOnce();
const callCfg = runWithModelFallbackMock.mock.calls[0][0].cfg;
const model = callCfg?.agents?.defaults?.model as
| { primary?: string; fallbacks?: string[] }
| undefined;
expect(model?.primary).toBe("openai-codex/gpt-5.3-codex");
expect(model?.fallbacks).toEqual(defaultFallbacks);
expectDefaultModelCall({
primary: "openai-codex/gpt-5.3-codex",
fallbacks: defaultFallbacks,
});
});
it("returns an error when payload.model is invalid", async () => {
@@ -483,8 +267,8 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
});
const result = await runCronIsolatedAgentTurn(
makeParams({
job: makeJob({
makeSkillParams({
job: makeSkillJob({
payload: { kind: "agentTurn", message: "test", model: "openai/" },
}),
}),
@@ -507,12 +291,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
meta: { agentMeta: { sessionId: "new-cli-session-xyz", usage: { input: 5, output: 10 } } },
});
// Make runWithModelFallback invoke the run callback so the CLI path executes.
runWithModelFallbackMock.mockImplementationOnce(
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
const result = await params.run("claude-cli", "claude-opus-4-6");
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
},
);
mockCliFallbackInvocation();
resolveCronSessionMock.mockReturnValue({
storePath: "/tmp/store.json",
store: {},
@@ -528,7 +307,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
isNewSession: true,
});
await runCronIsolatedAgentTurn(makeParams());
await runCronIsolatedAgentTurn(makeSkillParams());
expect(runCliAgentMock).toHaveBeenCalledOnce();
// Fresh session: cliSessionId must be undefined, not the stored value.
@@ -544,12 +323,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
agentMeta: { sessionId: "existing-cli-session-def", usage: { input: 5, output: 10 } },
},
});
runWithModelFallbackMock.mockImplementationOnce(
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => {
const result = await params.run("claude-cli", "claude-opus-4-6");
return { result, provider: "claude-cli", model: "claude-opus-4-6", attempts: [] };
},
);
mockCliFallbackInvocation();
resolveCronSessionMock.mockReturnValue({
storePath: "/tmp/store.json",
store: {},
@@ -564,7 +338,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
isNewSession: false,
});
await runCronIsolatedAgentTurn(makeParams());
await runCronIsolatedAgentTurn(makeSkillParams());
expect(runCliAgentMock).toHaveBeenCalledOnce();
// Continuation: cliSessionId should be passed through for session resume.

View File

@@ -0,0 +1,289 @@
import { vi } from "vitest";
type CronSessionEntry = {
sessionId: string;
updatedAt: number;
systemSent: boolean;
skillsSnapshot: unknown;
[key: string]: unknown;
};
type CronSession = {
storePath: string;
store: Record<string, unknown>;
sessionEntry: CronSessionEntry;
systemSent: boolean;
isNewSession: boolean;
[key: string]: unknown;
};
export const buildWorkspaceSkillSnapshotMock = vi.fn();
export const resolveAgentConfigMock = vi.fn();
export const resolveAgentModelFallbacksOverrideMock = vi.fn();
export const resolveAgentSkillsFilterMock = vi.fn();
export const getModelRefStatusMock = vi.fn();
export const isCliProviderMock = vi.fn();
export const resolveAllowedModelRefMock = vi.fn();
export const resolveConfiguredModelRefMock = vi.fn();
export const resolveHooksGmailModelMock = vi.fn();
export const resolveThinkingDefaultMock = vi.fn();
export const runWithModelFallbackMock = vi.fn();
export const runEmbeddedPiAgentMock = vi.fn();
export const runCliAgentMock = vi.fn();
export const getCliSessionIdMock = vi.fn();
export const updateSessionStoreMock = vi.fn();
export const resolveCronSessionMock = vi.fn();
export const logWarnMock = vi.fn();
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: resolveAgentConfigMock,
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
resolveAgentSkillsFilter: resolveAgentSkillsFilterMock,
}));
vi.mock("../../agents/skills.js", () => ({
buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock,
}));
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: [] }),
}));
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
return {
...actual,
getModelRefStatus: getModelRefStatusMock,
isCliProvider: isCliProviderMock,
resolveAllowedModelRef: resolveAllowedModelRefMock,
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
resolveHooksGmailModel: resolveHooksGmailModelMock,
resolveThinkingDefault: resolveThinkingDefaultMock,
};
});
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: runWithModelFallbackMock,
}));
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: runEmbeddedPiAgentMock,
}));
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: runCliAgentMock,
}));
vi.mock("../../agents/cli-session.js", () => ({
getCliSessionId: getCliSessionIdMock,
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({}),
}));
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<typeof import("../../routing/session-key.js")>();
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({}),
}));
vi.mock("../../logger.js", () => ({
logWarn: (...args: unknown[]) => logWarnMock(...args),
}));
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),
}));
vi.mock("./session.js", () => ({
resolveCronSession: resolveCronSessionMock,
}));
vi.mock("../../agents/defaults.js", () => ({
DEFAULT_CONTEXT_TOKENS: 128000,
DEFAULT_MODEL: "gpt-4",
DEFAULT_PROVIDER: "openai",
}));
export function makeCronSessionEntry(overrides?: Record<string, unknown>): CronSessionEntry {
return {
sessionId: "test-session-id",
updatedAt: 0,
systemSent: false,
skillsSnapshot: undefined,
...overrides,
};
}
export function makeCronSession(overrides?: Record<string, unknown>): CronSession {
return {
storePath: "/tmp/store.json",
store: {},
sessionEntry: makeCronSessionEntry(),
systemSent: false,
isNewSession: true,
...overrides,
} as CronSession;
}
function makeDefaultModelFallbackResult() {
return {
result: {
payloads: [{ text: "test output" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
},
provider: "openai",
model: "gpt-4",
};
}
function makeDefaultEmbeddedResult() {
return {
payloads: [{ text: "test output" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
};
}
export function resetRunCronIsolatedAgentTurnHarness(): void {
vi.clearAllMocks();
buildWorkspaceSkillSnapshotMock.mockReturnValue({
prompt: "<available_skills></available_skills>",
resolvedSkills: [],
version: 42,
});
resolveAgentConfigMock.mockReturnValue(undefined);
resolveAgentModelFallbacksOverrideMock.mockReturnValue(undefined);
resolveAgentSkillsFilterMock.mockReturnValue(undefined);
resolveConfiguredModelRefMock.mockReturnValue({ provider: "openai", model: "gpt-4" });
resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-4" } });
resolveHooksGmailModelMock.mockReturnValue(null);
resolveThinkingDefaultMock.mockReturnValue(undefined);
getModelRefStatusMock.mockReturnValue({ allowed: false });
isCliProviderMock.mockReturnValue(false);
runWithModelFallbackMock.mockReset();
runWithModelFallbackMock.mockResolvedValue(makeDefaultModelFallbackResult());
runEmbeddedPiAgentMock.mockReset();
runEmbeddedPiAgentMock.mockResolvedValue(makeDefaultEmbeddedResult());
runCliAgentMock.mockReset();
getCliSessionIdMock.mockReturnValue(undefined);
updateSessionStoreMock.mockReset();
updateSessionStoreMock.mockResolvedValue(undefined);
resolveCronSessionMock.mockReset();
resolveCronSessionMock.mockReturnValue(makeCronSession());
logWarnMock.mockReset();
}
export function clearFastTestEnv(): string | undefined {
const previousFastTestEnv = process.env.OPENCLAW_TEST_FAST;
delete process.env.OPENCLAW_TEST_FAST;
return previousFastTestEnv;
}
export function restoreFastTestEnv(previousFastTestEnv: string | undefined): void {
if (previousFastTestEnv == null) {
delete process.env.OPENCLAW_TEST_FAST;
return;
}
process.env.OPENCLAW_TEST_FAST = previousFastTestEnv;
}
export async function loadRunCronIsolatedAgentTurn() {
const { runCronIsolatedAgentTurn } = await import("./run.js");
return runCronIsolatedAgentTurn;
}