Compare commits

...

10 Commits

Author SHA1 Message Date
Tak Hoffman
6c491da2bd Trigger CI synchronize 2026-03-27 22:26:45 -05:00
Tak Hoffman
c7e6728b92 Stabilize slack interaction event mocks 2026-03-27 22:22:03 -05:00
Tak Hoffman
bb9ebc85fe Restore channel test module rebinding 2026-03-27 22:09:36 -05:00
Tak Hoffman
8a018a23ce Merge origin/main into codex/ci-green-refactor-alignment 2026-03-27 19:39:53 -05:00
Tak Hoffman
43df9e99f6 Restore self-hosted provider discovery mocks 2026-03-27 19:34:41 -05:00
Tak Hoffman
930468ba00 Restore skill source compatibility shims 2026-03-27 19:30:30 -05:00
Tak Hoffman
b22793ddda Stabilize Synology Chat module-bound tests 2026-03-27 19:12:29 -05:00
Tak Hoffman
7d4caf870a Handle SDK compaction and skill shape drift 2026-03-27 19:11:05 -05:00
Tak Hoffman
81e247c63d Harden exec completion after child exit 2026-03-27 19:10:41 -05:00
Tak Hoffman
b6e8e43611 Adjust compaction identifier test for summary args 2026-03-27 19:10:41 -05:00
26 changed files with 355 additions and 141 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ function resetMessageMocks(): void {
}
beforeAll(async () => {
vi.resetModules();
({ registerSlackMessageEvents } = await import("./messages.js"));
});

View File

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

View File

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

View File

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

View File

@@ -92,6 +92,7 @@ vi.mock("./runtime.js", () => ({
},
},
})),
setSynologyRuntime: vi.fn(),
}));
export function makeSecurityAccount(

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -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"];
}

View File

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

View File

@@ -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"];
}

View File

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

View File

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

View File

@@ -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"];
}

View File

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

View File

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