mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 10:42:43 +00:00
test(agents): dedupe agent and cron test scaffolds
This commit is contained in:
@@ -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> })
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user