mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
10 Commits
v2026.4.27
...
codex/ci-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c491da2bd | ||
|
|
c7e6728b92 | ||
|
|
bb9ebc85fe | ||
|
|
8a018a23ce | ||
|
|
43df9e99f6 | ||
|
|
930468ba00 | ||
|
|
b22793ddda | ||
|
|
7d4caf870a | ||
|
|
81e247c63d | ||
|
|
b6e8e43611 |
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
import {
|
||||
flush,
|
||||
@@ -71,11 +71,9 @@ beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ monitorSlackProvider } = await import("./monitor.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ monitorSlackProvider } = await import("./monitor.js"));
|
||||
resetInboundDedupe();
|
||||
resetSlackTestState({
|
||||
messages: { responsePrefix: "PFX" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js";
|
||||
import {
|
||||
defaultSlackTestConfig,
|
||||
@@ -21,14 +21,12 @@ let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
|
||||
const slackTestState = getSlackTestState();
|
||||
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js"));
|
||||
({ HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js"));
|
||||
({ CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js"));
|
||||
({ monitorSlackProvider } = await import("./monitor.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
resetSlackTestState(defaultSlackTestConfig());
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
|
||||
const readStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn());
|
||||
@@ -25,12 +25,10 @@ function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
|
||||
describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } =
|
||||
await import("./auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
readStoreAllowFromForDmPolicyMock.mockReset();
|
||||
clearSlackAllowFromCacheForTest();
|
||||
if (prevTtl === undefined) {
|
||||
|
||||
@@ -33,6 +33,7 @@ function createChannelContext(params?: {
|
||||
|
||||
describe("registerSlackChannelEvents", () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ registerSlackChannelEvents } = await import("./channels.js"));
|
||||
({ createSlackSystemEventTestHarness } = await import("./system-event-test-harness.js"));
|
||||
});
|
||||
|
||||
@@ -1,53 +1,21 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
|
||||
matched: false,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
}));
|
||||
const resolvePluginConversationBindingApprovalMock = vi.fn();
|
||||
const buildPluginBindingResolvedTextMock = vi.fn(() => "Binding updated.");
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
|
||||
(resolvePluginConversationBindingApprovalMock as (...innerArgs: unknown[]) => unknown)(
|
||||
...args,
|
||||
),
|
||||
buildPluginBindingResolvedText: (...args: unknown[]) =>
|
||||
(buildPluginBindingResolvedTextMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchPluginInteractiveHandlerMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
matched: false,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
})),
|
||||
);
|
||||
const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
|
||||
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn(() => "Binding updated."));
|
||||
|
||||
let registerSlackInteractionEvents: typeof import("./interactions.js").registerSlackInteractionEvents;
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
let enqueueSystemEventSpy: ReturnType<typeof vi.spyOn>;
|
||||
let dispatchPluginInteractiveHandlerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let resolvePluginConversationBindingApprovalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let buildPluginBindingResolvedTextSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
type RegisteredHandler = (args: {
|
||||
ack: () => Promise<void>;
|
||||
@@ -198,10 +166,50 @@ function createContext(overrides?: {
|
||||
|
||||
describe("registerSlackInteractionEvents", () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
const infraRuntime = await import("openclaw/plugin-sdk/infra-runtime");
|
||||
const pluginRuntime = await import("openclaw/plugin-sdk/plugin-runtime");
|
||||
const conversationBinding = await import("../../../../../src/plugins/conversation-binding.js");
|
||||
enqueueSystemEventSpy = vi
|
||||
.spyOn(infraRuntime, "enqueueSystemEvent")
|
||||
.mockImplementation(((...args: Parameters<typeof infraRuntime.enqueueSystemEvent>) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => boolean)(
|
||||
...args,
|
||||
)) as typeof infraRuntime.enqueueSystemEvent);
|
||||
dispatchPluginInteractiveHandlerSpy = vi
|
||||
.spyOn(pluginRuntime, "dispatchPluginInteractiveHandler")
|
||||
.mockImplementation(((
|
||||
...args: Parameters<typeof pluginRuntime.dispatchPluginInteractiveHandler>
|
||||
) =>
|
||||
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => Promise<unknown>)(
|
||||
...args,
|
||||
)) as typeof pluginRuntime.dispatchPluginInteractiveHandler);
|
||||
resolvePluginConversationBindingApprovalSpy = vi
|
||||
.spyOn(conversationBinding, "resolvePluginConversationBindingApproval")
|
||||
.mockImplementation(((
|
||||
...args: Parameters<typeof conversationBinding.resolvePluginConversationBindingApproval>
|
||||
) =>
|
||||
(
|
||||
resolvePluginConversationBindingApprovalMock as (
|
||||
...innerArgs: unknown[]
|
||||
) => Promise<unknown>
|
||||
)(...args)) as typeof conversationBinding.resolvePluginConversationBindingApproval);
|
||||
buildPluginBindingResolvedTextSpy = vi
|
||||
.spyOn(conversationBinding, "buildPluginBindingResolvedText")
|
||||
.mockImplementation(((
|
||||
...args: Parameters<typeof conversationBinding.buildPluginBindingResolvedText>
|
||||
) =>
|
||||
(buildPluginBindingResolvedTextMock as (...innerArgs: unknown[]) => string)(
|
||||
...args,
|
||||
)) as typeof conversationBinding.buildPluginBindingResolvedText);
|
||||
({ registerSlackInteractionEvents } = await import("./interactions.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
dispatchPluginInteractiveHandlerSpy.mockClear();
|
||||
resolvePluginConversationBindingApprovalSpy.mockClear();
|
||||
buildPluginBindingResolvedTextSpy.mockClear();
|
||||
enqueueSystemEventMock.mockClear();
|
||||
dispatchPluginInteractiveHandlerMock.mockClear();
|
||||
resolvePluginConversationBindingApprovalMock.mockClear();
|
||||
|
||||
@@ -66,6 +66,7 @@ async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
|
||||
|
||||
describe("registerSlackMemberEvents", () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ registerSlackMemberEvents } = await import("./members.js"));
|
||||
({ createSlackSystemEventTestHarness: initSlackHarness } =
|
||||
await import("./system-event-test-harness.js"));
|
||||
|
||||
@@ -53,6 +53,7 @@ function resetMessageMocks(): void {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ registerSlackMessageEvents } = await import("./messages.js"));
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ async function runPinCase(input: PinCase = {}): Promise<void> {
|
||||
|
||||
describe("registerSlackPinEvents", () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ registerSlackPinEvents } = await import("./pins.js"));
|
||||
({ createSlackSystemEventTestHarness: buildPinHarness } =
|
||||
await import("./system-event-test-harness.js"));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const prepareSlackMessageMock =
|
||||
vi.fn<
|
||||
@@ -117,11 +117,9 @@ async function createInFlightMessageScenario(ts: string) {
|
||||
}
|
||||
|
||||
describe("createSlackMessageHandler app_mention race handling", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ createSlackMessageHandler } = await import("./message-handler.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
prepareSlackMessageMock.mockReset();
|
||||
dispatchPreparedSlackMessageMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -14,9 +14,12 @@ type RegisteredRoute = {
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||
let createSynologyChatPlugin: typeof import("./channel.js").createSynologyChatPlugin;
|
||||
const freshChannelModulePath: string = "./channel.js?channel-integration-test";
|
||||
describe("Synology channel wiring integration", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ createSynologyChatPlugin } = await import(freshChannelModulePath));
|
||||
registerPluginHttpRouteMock.mockClear();
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
finalizeInboundContextMock.mockClear();
|
||||
|
||||
@@ -92,6 +92,7 @@ vi.mock("./runtime.js", () => ({
|
||||
},
|
||||
},
|
||||
})),
|
||||
setSynologyRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
export function makeSecurityAccount(
|
||||
|
||||
@@ -34,7 +34,8 @@ vi.mock("./webhook-handler.js", () => ({
|
||||
createWebhookHandler: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||
const freshChannelModulePath = "./channel.js?channel-test";
|
||||
const { createSynologyChatPlugin } = await import(freshChannelModulePath);
|
||||
|
||||
describe("createSynologyChatPlugin", () => {
|
||||
beforeEach(() => {
|
||||
@@ -548,19 +549,15 @@ describe("createSynologyChatPlugin", () => {
|
||||
abortSignal: abortCtrl.signal,
|
||||
});
|
||||
|
||||
// Start first account (returns a pending promise)
|
||||
const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst));
|
||||
// Start second account on same path — should deregister the first route
|
||||
const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond));
|
||||
|
||||
// Give microtasks time to settle
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(registerMock).toHaveBeenCalledTimes(2);
|
||||
expect(unregisterFirst).not.toHaveBeenCalled();
|
||||
expect(unregisterSecond).not.toHaveBeenCalled();
|
||||
|
||||
// Clean up: abort both to resolve promises and prevent test leak
|
||||
abortFirst.abort();
|
||||
abortSecond.abort();
|
||||
await Promise.allSettled([firstPromise, secondPromise]);
|
||||
|
||||
@@ -14,11 +14,12 @@ vi.mock("node:http", () => {
|
||||
return { default: { request: mockRequest, get: mockGet }, request: mockRequest, get: mockGet };
|
||||
});
|
||||
|
||||
// Import after mocks are set up
|
||||
const { sendMessage, sendFileUrl, fetchChatUsers, resolveLegacyWebhookNameToChatUserId } =
|
||||
await import("./client.js");
|
||||
const https = await import("node:https");
|
||||
let fakeNowMs = 1_700_000_000_000;
|
||||
let sendMessage: typeof import("./client.js").sendMessage;
|
||||
let sendFileUrl: typeof import("./client.js").sendFileUrl;
|
||||
let fetchChatUsers: typeof import("./client.js").fetchChatUsers;
|
||||
let resolveLegacyWebhookNameToChatUserId: typeof import("./client.js").resolveLegacyWebhookNameToChatUserId;
|
||||
|
||||
async function settleTimers<T>(promise: Promise<T>): Promise<T> {
|
||||
await Promise.resolve();
|
||||
@@ -55,6 +56,7 @@ function mockFailureResponse(statusCode = 500) {
|
||||
function installFakeTimerHarness() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
fakeNowMs += 10_000;
|
||||
vi.setSystemTime(fakeNowMs);
|
||||
@@ -63,6 +65,11 @@ function installFakeTimerHarness() {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
({ sendMessage, sendFileUrl, fetchChatUsers, resolveLegacyWebhookNameToChatUserId } =
|
||||
await import("./client.js"));
|
||||
});
|
||||
}
|
||||
|
||||
describe("sendMessage", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { sendMessage, resolveLegacyWebhookNameToChatUserId } from "./client.js";
|
||||
import * as synologyClient from "./client.js";
|
||||
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
|
||||
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
@@ -481,7 +481,7 @@ async function resolveSynologyReplyDeliveryUserId(params: {
|
||||
return params.payload.user_id;
|
||||
}
|
||||
|
||||
const resolvedChatApiUserId = await resolveLegacyWebhookNameToChatUserId({
|
||||
const resolvedChatApiUserId = await synologyClient.resolveLegacyWebhookNameToChatUserId({
|
||||
incomingUrl: params.account.incomingUrl,
|
||||
mutableWebhookUsername: params.payload.username,
|
||||
allowInsecureSsl: params.account.allowInsecureSsl,
|
||||
@@ -529,7 +529,7 @@ async function processAuthorizedSynologyWebhook(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(
|
||||
await synologyClient.sendMessage(
|
||||
params.account.incomingUrl,
|
||||
reply,
|
||||
deliveryUserId,
|
||||
@@ -544,7 +544,7 @@ async function processAuthorizedSynologyWebhook(params: {
|
||||
params.log?.error?.(
|
||||
`Failed to process message from ${params.message.payload.username}: ${errMsg}`,
|
||||
);
|
||||
await sendMessage(
|
||||
await synologyClient.sendMessage(
|
||||
params.account.incomingUrl,
|
||||
"Sorry, an error occurred while processing your message.",
|
||||
deliveryUserId,
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("compaction identifier-preservation instructions", () => {
|
||||
}
|
||||
|
||||
function firstSummaryInstructions() {
|
||||
return mockGenerateSummary.mock.calls[0]?.[6];
|
||||
return extractSummaryInstructions(mockGenerateSummary.mock.calls[0]);
|
||||
}
|
||||
|
||||
it("injects identifier-preservation guidance even without custom instructions", async () => {
|
||||
@@ -101,7 +101,9 @@ describe("compaction identifier-preservation instructions", () => {
|
||||
|
||||
expect(mockGenerateSummary.mock.calls.length).toBeGreaterThan(1);
|
||||
for (const call of mockGenerateSummary.mock.calls) {
|
||||
expect(call[6]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(extractSummaryInstructions(call)).toContain(
|
||||
"Preserve all opaque identifiers exactly as written",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,13 +116,31 @@ describe("compaction identifier-preservation instructions", () => {
|
||||
});
|
||||
|
||||
const mergedCall = mockGenerateSummary.mock.calls.at(-1);
|
||||
const instructions = mergedCall?.[6] ?? "";
|
||||
const instructions = extractSummaryInstructions(mergedCall);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
function extractSummaryInstructions(call: unknown[] | undefined): string {
|
||||
if (!call) {
|
||||
return "";
|
||||
}
|
||||
for (let index = call.length - 1; index >= 4; index -= 1) {
|
||||
const arg = call[index];
|
||||
if (
|
||||
typeof arg === "string" &&
|
||||
(arg.includes("Preserve all opaque identifiers exactly as written") ||
|
||||
arg.includes("Merge these partial summaries into a single cohesive summary.") ||
|
||||
arg.includes("Additional focus:"))
|
||||
) {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
describe("buildCompactionSummarizationInstructions", () => {
|
||||
it("returns base instructions when no custom text is provided", () => {
|
||||
const result = buildCompactionSummarizationInstructions();
|
||||
|
||||
@@ -15,6 +15,17 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||
});
|
||||
|
||||
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
|
||||
type MockGenerateSummaryCompat = (
|
||||
currentMessages: AgentMessage[],
|
||||
model: NonNullable<ExtensionContext["model"]>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
) => Promise<string>;
|
||||
const mockGenerateSummaryCompat = mockGenerateSummary as unknown as MockGenerateSummaryCompat;
|
||||
|
||||
describe("compaction retry integration", () => {
|
||||
beforeEach(() => {
|
||||
@@ -56,7 +67,7 @@ describe("compaction retry integration", () => {
|
||||
} as unknown as NonNullable<ExtensionContext["model"]>;
|
||||
|
||||
const invokeGenerateSummary = (signal = new AbortController().signal) =>
|
||||
mockGenerateSummary(testMessages, testModel, 1000, "test-api-key", undefined, signal);
|
||||
mockGenerateSummaryCompat(testMessages, testModel, 1000, "test-api-key", undefined, signal);
|
||||
|
||||
const runSummaryRetry = (options: Parameters<typeof retryAsync>[1]) =>
|
||||
retryAsync(() => invokeGenerateSummary(), options);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
import { estimateTokens, generateSummary as piGenerateSummary } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentCompactionIdentifierPolicy } from "../config/types.agent-defaults.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@@ -37,6 +37,30 @@ export type CompactionSummarizationInstructions = {
|
||||
identifierInstructions?: string;
|
||||
};
|
||||
|
||||
type GenerateSummaryCompat = {
|
||||
(
|
||||
currentMessages: AgentMessage[],
|
||||
model: NonNullable<ExtensionContext["model"]>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
): Promise<string>;
|
||||
(
|
||||
currentMessages: AgentMessage[],
|
||||
model: NonNullable<ExtensionContext["model"]>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
): Promise<string>;
|
||||
};
|
||||
|
||||
const generateSummaryCompat = piGenerateSummary as unknown as GenerateSummaryCompat;
|
||||
|
||||
function resolveIdentifierPreservationInstructions(
|
||||
instructions?: CompactionSummarizationInstructions,
|
||||
): string | undefined {
|
||||
@@ -259,6 +283,39 @@ async function summarizeChunks(params: {
|
||||
return summary ?? DEFAULT_SUMMARY_FALLBACK;
|
||||
}
|
||||
|
||||
function generateSummary(
|
||||
currentMessages: AgentMessage[],
|
||||
model: NonNullable<ExtensionContext["model"]>,
|
||||
reserveTokens: number,
|
||||
apiKey: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
signal: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
): Promise<string> {
|
||||
if (piGenerateSummary.length >= 8) {
|
||||
return generateSummaryCompat(
|
||||
currentMessages,
|
||||
model,
|
||||
reserveTokens,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
);
|
||||
}
|
||||
return generateSummaryCompat(
|
||||
currentMessages,
|
||||
model,
|
||||
reserveTokens,
|
||||
apiKey,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize with progressive fallback for handling oversized messages.
|
||||
* If full summarization fails, tries partial summarization excluding oversized messages.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createSyntheticSourceInfo } from "@mariozechner/pi-coding-agent";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installDownloadSpec } from "./skills-install-download.js";
|
||||
import { setTempStateDir } from "./skills-install.download-test-utils.js";
|
||||
@@ -56,21 +55,36 @@ const TAR_GZ_TRAVERSAL_BUFFER = Buffer.from(
|
||||
function buildEntry(name: string): SkillEntry {
|
||||
const skillDir = path.join(workspaceDir, "skills", name);
|
||||
return {
|
||||
skill: {
|
||||
skill: createFixtureSkill({
|
||||
name,
|
||||
description: `${name} test skill`,
|
||||
filePath: path.join(skillDir, "SKILL.md"),
|
||||
baseDir: skillDir,
|
||||
sourceInfo: createSyntheticSourceInfo(path.join(skillDir, "SKILL.md"), {
|
||||
source: "openclaw-workspace",
|
||||
baseDir: skillDir,
|
||||
}),
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
source: "openclaw-workspace",
|
||||
}),
|
||||
frontmatter: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createFixtureSkill(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: string;
|
||||
}): SkillEntry["skill"] {
|
||||
const skill = {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
filePath: params.filePath,
|
||||
baseDir: params.baseDir,
|
||||
source: params.source,
|
||||
sourceInfo: { source: params.source },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
return skill as unknown as SkillEntry["skill"];
|
||||
}
|
||||
|
||||
function buildDownloadSpec(params: {
|
||||
url: string;
|
||||
archive: "tar.gz" | "tar.bz2" | "zip";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createSyntheticSourceInfo } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceSkillStatus } from "./skills-status.js";
|
||||
import type { SkillEntry } from "./skills/types.js";
|
||||
@@ -13,17 +12,13 @@ describe("buildWorkspaceSkillStatus", () => {
|
||||
const mismatchedOs = process.platform === "darwin" ? "linux" : "darwin";
|
||||
|
||||
const entry: SkillEntry = {
|
||||
skill: {
|
||||
skill: createFixtureSkill({
|
||||
name: "os-scoped",
|
||||
description: "test",
|
||||
filePath: "/tmp/os-scoped",
|
||||
baseDir: "/tmp",
|
||||
sourceInfo: createSyntheticSourceInfo("/tmp/os-scoped", {
|
||||
source: "test",
|
||||
baseDir: "/tmp",
|
||||
}),
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
source: "test",
|
||||
}),
|
||||
frontmatter: {},
|
||||
metadata: {
|
||||
os: [mismatchedOs],
|
||||
@@ -45,3 +40,22 @@ describe("buildWorkspaceSkillStatus", () => {
|
||||
expect(report.skills[0]?.install).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function createFixtureSkill(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: string;
|
||||
}): SkillEntry["skill"] {
|
||||
const skill = {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
filePath: params.filePath,
|
||||
baseDir: params.baseDir,
|
||||
source: params.source,
|
||||
sourceInfo: { source: params.source },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
return skill as unknown as SkillEntry["skill"];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createSyntheticSourceInfo } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { buildWorkspaceSkillStatus } from "./skills-status.js";
|
||||
@@ -20,17 +19,13 @@ function makeEntry(params: {
|
||||
}>;
|
||||
}): SkillEntry {
|
||||
return {
|
||||
skill: {
|
||||
skill: createFixtureSkill({
|
||||
name: params.name,
|
||||
description: `desc:${params.name}`,
|
||||
filePath: `/tmp/${params.name}/SKILL.md`,
|
||||
baseDir: `/tmp/${params.name}`,
|
||||
sourceInfo: createSyntheticSourceInfo(`/tmp/${params.name}/SKILL.md`, {
|
||||
source: params.source ?? "openclaw-workspace",
|
||||
baseDir: `/tmp/${params.name}`,
|
||||
}),
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
source: params.source ?? "openclaw-workspace",
|
||||
}),
|
||||
frontmatter: {},
|
||||
metadata: {
|
||||
...(params.os ? { os: params.os } : {}),
|
||||
@@ -41,6 +36,25 @@ function makeEntry(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createFixtureSkill(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: string;
|
||||
}): SkillEntry["skill"] {
|
||||
const skill = {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
filePath: params.filePath,
|
||||
baseDir: params.baseDir,
|
||||
source: params.source,
|
||||
sourceInfo: { source: params.source },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
return skill as unknown as SkillEntry["skill"];
|
||||
}
|
||||
|
||||
describe("buildWorkspaceSkillStatus", () => {
|
||||
it("reports missing requirements and install options", async () => {
|
||||
const entry = makeEntry({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createSyntheticSourceInfo } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSkillsPromptForRun } from "./skills.js";
|
||||
import type { SkillEntry } from "./skills/types.js";
|
||||
@@ -13,17 +12,13 @@ describe("resolveSkillsPromptForRun", () => {
|
||||
});
|
||||
it("builds prompt from entries when snapshot is missing", () => {
|
||||
const entry: SkillEntry = {
|
||||
skill: {
|
||||
skill: createFixtureSkill({
|
||||
name: "demo-skill",
|
||||
description: "Demo",
|
||||
filePath: "/app/skills/demo-skill/SKILL.md",
|
||||
baseDir: "/app/skills/demo-skill",
|
||||
sourceInfo: createSyntheticSourceInfo("/app/skills/demo-skill/SKILL.md", {
|
||||
source: "openclaw-bundled",
|
||||
baseDir: "/app/skills/demo-skill",
|
||||
}),
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
source: "openclaw-bundled",
|
||||
}),
|
||||
frontmatter: {},
|
||||
};
|
||||
const prompt = resolveSkillsPromptForRun({
|
||||
@@ -34,3 +29,22 @@ describe("resolveSkillsPromptForRun", () => {
|
||||
expect(prompt).toContain("/app/skills/demo-skill/SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
function createFixtureSkill(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: string;
|
||||
}): SkillEntry["skill"] {
|
||||
const skill = {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
filePath: params.filePath,
|
||||
baseDir: params.baseDir,
|
||||
source: params.source,
|
||||
sourceInfo: { source: params.source },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
return skill as unknown as SkillEntry["skill"];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import os from "node:os";
|
||||
import {
|
||||
createSyntheticSourceInfo,
|
||||
formatSkillsForPrompt,
|
||||
type Skill,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SkillEntry } from "./types.js";
|
||||
@@ -14,17 +10,16 @@ import {
|
||||
} from "./workspace.js";
|
||||
|
||||
function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill {
|
||||
return {
|
||||
const skill = {
|
||||
name,
|
||||
description: desc,
|
||||
filePath,
|
||||
baseDir: `/skills/${name}`,
|
||||
sourceInfo: createSyntheticSourceInfo(filePath, {
|
||||
source: "workspace",
|
||||
baseDir: `/skills/${name}`,
|
||||
}),
|
||||
source: "workspace",
|
||||
sourceInfo: { source: "workspace" },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
return skill as unknown as Skill;
|
||||
}
|
||||
|
||||
function makeEntry(skill: Skill): SkillEntry {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
type SkillSourceShapeCompat = Skill & {
|
||||
source?: string;
|
||||
sourceInfo?: {
|
||||
source?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveSkillSource(skill: Skill): string {
|
||||
return skill.sourceInfo.source;
|
||||
const compatSkill = skill as SkillSourceShapeCompat;
|
||||
return compatSkill.source ?? compatSkill.sourceInfo?.source ?? "unknown";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createSyntheticSourceInfo } from "@mariozechner/pi-coding-agent";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import type { SkillEntry } from "../agents/skills.js";
|
||||
@@ -34,17 +33,13 @@ describe("skills-cli (e2e)", () => {
|
||||
const baseDir = path.join(tempWorkspaceDir, "peekaboo");
|
||||
return [
|
||||
{
|
||||
skill: {
|
||||
skill: createFixtureSkill({
|
||||
name: "peekaboo",
|
||||
description: "Capture UI screenshots",
|
||||
filePath: path.join(baseDir, "SKILL.md"),
|
||||
baseDir,
|
||||
sourceInfo: createSyntheticSourceInfo(path.join(baseDir, "SKILL.md"), {
|
||||
source: "openclaw-bundled",
|
||||
baseDir,
|
||||
}),
|
||||
disableModelInvocation: false,
|
||||
},
|
||||
source: "openclaw-bundled",
|
||||
}),
|
||||
frontmatter: {},
|
||||
metadata: { emoji: "📸" },
|
||||
},
|
||||
@@ -88,3 +83,22 @@ describe("skills-cli (e2e)", () => {
|
||||
expect(output).toContain("Details:");
|
||||
});
|
||||
});
|
||||
|
||||
function createFixtureSkill(params: {
|
||||
name: string;
|
||||
description: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: string;
|
||||
}): SkillEntry["skill"] {
|
||||
const skill = {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
filePath: params.filePath,
|
||||
baseDir: params.baseDir,
|
||||
source: params.source,
|
||||
sourceInfo: { source: params.source },
|
||||
disableModelInvocation: false,
|
||||
};
|
||||
return skill as unknown as SkillEntry["skill"];
|
||||
}
|
||||
|
||||
@@ -246,6 +246,8 @@ export async function runCommandWithTimeout(
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let noOutputTimedOut = false;
|
||||
let childExitState: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
let closeFallbackTimer: NodeJS.Timeout | null = null;
|
||||
let noOutputTimer: NodeJS.Timeout | null = null;
|
||||
const shouldTrackOutputTimeout =
|
||||
typeof noOutputTimeoutMs === "number" &&
|
||||
@@ -260,6 +262,14 @@ export async function runCommandWithTimeout(
|
||||
noOutputTimer = null;
|
||||
};
|
||||
|
||||
const clearCloseFallbackTimer = () => {
|
||||
if (!closeFallbackTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(closeFallbackTimer);
|
||||
closeFallbackTimer = null;
|
||||
};
|
||||
|
||||
const armNoOutputTimer = () => {
|
||||
if (!shouldTrackOutputTimeout || settled) {
|
||||
return;
|
||||
@@ -304,8 +314,22 @@ export async function runCommandWithTimeout(
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearNoOutputTimer();
|
||||
clearCloseFallbackTimer();
|
||||
reject(err);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
childExitState = { code, signal };
|
||||
if (settled || closeFallbackTimer) {
|
||||
return;
|
||||
}
|
||||
closeFallbackTimer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
}, 250);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) {
|
||||
return;
|
||||
@@ -313,25 +337,28 @@ export async function runCommandWithTimeout(
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearNoOutputTimer();
|
||||
clearCloseFallbackTimer();
|
||||
const resolvedCode = childExitState?.code ?? code;
|
||||
const resolvedSignal = childExitState?.signal ?? signal;
|
||||
const termination = noOutputTimedOut
|
||||
? "no-output-timeout"
|
||||
: timedOut
|
||||
? "timeout"
|
||||
: signal != null
|
||||
: resolvedSignal != null
|
||||
? "signal"
|
||||
: "exit";
|
||||
const normalizedCode =
|
||||
termination === "timeout" || termination === "no-output-timeout"
|
||||
? code === 0
|
||||
? resolvedCode === 0
|
||||
? 124
|
||||
: code
|
||||
: code;
|
||||
: resolvedCode
|
||||
: resolvedCode;
|
||||
resolve({
|
||||
pid: child.pid ?? undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
code: normalizedCode,
|
||||
signal,
|
||||
signal: resolvedSignal,
|
||||
killed: child.killed,
|
||||
termination,
|
||||
noOutputTimedOut,
|
||||
|
||||
@@ -149,6 +149,20 @@ function installDiscoveryHooks(state: DiscoveryState) {
|
||||
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
|
||||
};
|
||||
});
|
||||
vi.doMock("../../../extensions/vllm/api.js", async () => {
|
||||
const actual = await vi.importActual<object>("../../../extensions/vllm/api.js");
|
||||
return {
|
||||
...actual,
|
||||
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
|
||||
};
|
||||
});
|
||||
vi.doMock("../../../extensions/sglang/api.js", async () => {
|
||||
const actual = await vi.importActual<object>("../../../extensions/sglang/api.js");
|
||||
return {
|
||||
...actual,
|
||||
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
|
||||
};
|
||||
});
|
||||
({ runProviderCatalog: state.runProviderCatalog } =
|
||||
await import("../../../src/plugins/provider-discovery.js"));
|
||||
const [
|
||||
|
||||
Reference in New Issue
Block a user