test: eliminate unchecked TypeScript casts (#105804)

This commit is contained in:
Peter Steinberger
2026-07-12 18:08:26 -07:00
committed by GitHub
parent f9d7aa286e
commit 774d5249cc
57 changed files with 1506 additions and 869 deletions
@@ -65,7 +65,7 @@ export function makeBrowserServerState(params?: {
},
};
return {
server: null as any,
server: null as unknown as BrowserServerState["server"],
port: 0,
resolved: {
...resolvedBase,
+156 -108
View File
@@ -1,5 +1,11 @@
// Copilot tests cover harness plugin behavior.
import type { CopilotClient } from "@github/copilot-sdk";
import { attachModelProviderRequestTransport } from "openclaw/plugin-sdk/agent-harness-runtime";
import type {
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
AgentHarnessCompactParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
@@ -8,7 +14,9 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CopilotClientPool } from "./harness.js";
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
import type { resolvePoolAcquire } from "./src/attempt.js";
import { COPILOT_BYOK_PROVIDER_ERROR } from "./src/provider-bridge.js";
import type { PoolKey } from "./src/runtime.js";
const mocks = vi.hoisted(() => ({
runCopilotAttempt: vi.fn(),
@@ -22,7 +30,8 @@ const mocks = vi.hoisted(() => ({
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
}) as any,
provider: { mode: "github-copilot" },
}) as ReturnType<typeof resolvePoolAcquire>,
),
createCopilotByokProxy: vi.fn(),
createCopilotClientPool: vi.fn(),
@@ -41,8 +50,24 @@ vi.mock("./src/runtime.js", () => ({
createCopilotClientPool: mocks.createCopilotClientPool,
}));
const ATTEMPT_PARAMS = { provider: "github-copilot", model: "gpt-4.1" } as any;
const ATTEMPT_RESULT = { ok: true } as any;
function asAttemptParams(value: Record<string, unknown>): AgentHarnessAttemptParams {
return value as unknown as AgentHarnessAttemptParams;
}
function asAttemptResult(value: Record<string, unknown>): AgentHarnessAttemptResult {
return value as unknown as AgentHarnessAttemptResult;
}
const ATTEMPT_PARAMS = asAttemptParams({
provider: "github-copilot",
model: "gpt-4.1",
});
const ATTEMPT_RESULT = asAttemptResult({ ok: true });
const TEST_POOL_KEY = {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/tmp/copilot",
} satisfies PoolKey;
const TEST_SESSION_CONFIG = {
availableTools: [],
model: "gpt-4.1",
@@ -50,6 +75,10 @@ const TEST_SESSION_CONFIG = {
workingDirectory: "/workspace",
};
function createMockCopilotClient(overrides: Record<string, unknown> = {}): CopilotClient {
return overrides as unknown as CopilotClient;
}
function makePoolMock() {
return {
acquire: vi.fn(),
@@ -104,6 +133,7 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
provider: { mode: "github-copilot" },
});
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
mocks.createCopilotByokProxy.mockResolvedValue(undefined);
@@ -184,7 +214,7 @@ describe("createCopilotAgentHarness", () => {
harness.supports({
provider: "github-copilot",
modelId: "gpt-4.1",
requestedRuntime: " COPILOT " as any,
requestedRuntime: " COPILOT ",
}),
).toEqual({ supported: true, priority: 100 });
});
@@ -321,8 +351,8 @@ describe("createCopilotAgentHarness", () => {
it("runAttempt creates one pool lazily and reuses it across two attempts on the same harness", async () => {
const pool = makePoolMock();
const firstResult = { attempt: 1 } as any;
const secondResult = { attempt: 2 } as any;
const firstResult = asAttemptResult({ attempt: 1 });
const secondResult = asAttemptResult({ attempt: 2 });
mocks.createCopilotClientPool.mockReturnValue(pool);
mocks.runCopilotAttempt.mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult);
const harness = createCopilotAgentHarness();
@@ -368,8 +398,8 @@ describe("createCopilotAgentHarness", () => {
it("runAttempt does not serialize concurrent attempts", async () => {
const pool = makePoolMock();
const firstResult = { attempt: 1 } as any;
const secondResult = { attempt: 2 } as any;
const firstResult = asAttemptResult({ attempt: 1 });
const secondResult = asAttemptResult({ attempt: 2 });
mocks.createCopilotClientPool.mockReturnValue(pool);
mocks.runCopilotAttempt.mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult);
const harness = createCopilotAgentHarness();
@@ -420,7 +450,7 @@ describe("createCopilotAgentHarness", () => {
it("dispose waits for in-flight runAttempt before disposing", async () => {
const pool = makePoolMock();
const deferred = createDeferred<any>();
const deferred = createDeferred<AgentHarnessAttemptResult>();
mocks.createCopilotClientPool.mockReturnValue(pool);
mocks.runCopilotAttempt.mockImplementation(() => deferred.promise);
const harness = createCopilotAgentHarness();
@@ -517,11 +547,11 @@ describe("createCopilotAgentHarness", () => {
it("calls deleteSession on the client that created the session", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
const client = createMockCopilotClient({ deleteSession });
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-123",
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -549,11 +579,11 @@ describe("createCopilotAgentHarness", () => {
it("swallows errors thrown by client.deleteSession", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockRejectedValue(new Error("session not found"));
const client = { deleteSession } as any;
const client = createMockCopilotClient({ deleteSession });
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-err",
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -568,11 +598,11 @@ describe("createCopilotAgentHarness", () => {
it("forgets the session after reset; a second reset is a no-op", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
const client = createMockCopilotClient({ deleteSession });
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-x",
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -588,11 +618,11 @@ describe("createCopilotAgentHarness", () => {
it("does not invoke deleteSession for a session belonging to a different openclawSessionId", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
const client = createMockCopilotClient({ deleteSession });
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-y",
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -608,11 +638,11 @@ describe("createCopilotAgentHarness", () => {
it("dispose clears tracked sessions so subsequent reset is a no-op", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
const client = createMockCopilotClient({ deleteSession });
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-d",
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -631,7 +661,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-pending-cleanup",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -655,7 +685,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-reset-cleanup",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -685,7 +715,10 @@ describe("createCopilotAgentHarness", () => {
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-before-reset",
pooledClient: { key: {} as any, client: { deleteSession: oldDeleteSession } as any },
pooledClient: {
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: oldDeleteSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -697,8 +730,8 @@ describe("createCopilotAgentHarness", () => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-replacement",
pooledClient: {
key: {} as any,
client: { deleteSession: replacementDeleteSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: replacementDeleteSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -734,7 +767,7 @@ describe("createCopilotAgentHarness", () => {
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-before-reset",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -746,8 +779,8 @@ describe("createCopilotAgentHarness", () => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-replacement",
pooledClient: {
key: {} as any,
client: { deleteSession: replacementDeleteSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: replacementDeleteSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -755,8 +788,8 @@ describe("createCopilotAgentHarness", () => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-during-reset",
pooledClient: {
key: {} as any,
client: { deleteSession: duringResetDeleteSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: duringResetDeleteSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -810,11 +843,11 @@ describe("createCopilotAgentHarness", () => {
it("seeds initialReplayState.sdkSessionId from trackedSessions on the second turn", async () => {
const pool = makePoolMock();
const client = { deleteSession: vi.fn() } as any;
const client = createMockCopilotClient({ deleteSession: vi.fn() });
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-warm",
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -842,7 +875,7 @@ describe("createCopilotAgentHarness", () => {
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-compacting",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -882,7 +915,7 @@ describe("createCopilotAgentHarness", () => {
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-old",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -893,7 +926,7 @@ describe("createCopilotAgentHarness", () => {
} else if (attempt === 2) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-replacement",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
}
@@ -923,7 +956,7 @@ describe("createCopilotAgentHarness", () => {
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cancelled",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -966,7 +999,7 @@ describe("createCopilotAgentHarness", () => {
if (attempt === 1) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-stale",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
firstAttemptDeps = deps;
@@ -974,7 +1007,7 @@ describe("createCopilotAgentHarness", () => {
} else if (attempt === 2) {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-current",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
}
@@ -1008,7 +1041,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cold",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1027,7 +1060,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-gpt4",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1054,7 +1087,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-api",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1084,7 +1117,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-auth1",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1125,7 +1158,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-p1",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1165,7 +1198,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tok1",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1199,7 +1232,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tracked",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1225,12 +1258,12 @@ describe("createCopilotAgentHarness", () => {
it("updates the tracked session when onSessionEstablished reports a new sdkSessionId", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn();
const client = { deleteSession } as any;
const client = createMockCopilotClient({ deleteSession });
let nextSdkId = "sdk-sess-1";
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: nextSdkId,
pooledClient: { key: {} as any, client },
pooledClient: { key: TEST_POOL_KEY, client },
});
return ATTEMPT_RESULT;
});
@@ -1254,7 +1287,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-sqlite",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1288,7 +1321,10 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-byok",
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
pooledClient: {
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn() }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
@@ -1330,7 +1366,10 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-byok",
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
pooledClient: {
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn() }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
@@ -1370,7 +1409,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-current",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1442,7 +1481,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-memory-only",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1467,7 +1506,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-old-model",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1501,7 +1540,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-main-home",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1544,7 +1583,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tracked-model",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1606,7 +1645,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-reset",
pooledClient: { key: {} as any, client: { deleteSession } as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient({ deleteSession }) },
});
return ATTEMPT_RESULT;
});
@@ -1626,7 +1665,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-before-reset",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
});
return ATTEMPT_RESULT;
});
@@ -1674,7 +1713,9 @@ describe("createCopilotAgentHarness", () => {
it("returns ok:false when sessionId is missing", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ workspaceDir: "/ws" } as any);
const result = await harness.compact?.({
workspaceDir: "/ws",
} as AgentHarnessCompactParams);
expect(result).toEqual({
ok: false,
compacted: false,
@@ -1688,7 +1729,7 @@ describe("createCopilotAgentHarness", () => {
sessionId: "oc-sess-compact-1",
trigger: "budget",
currentTokenCount: 12345,
} as any);
} as AgentHarnessCompactParams);
expect(result).toEqual({
ok: false,
@@ -1704,7 +1745,7 @@ describe("createCopilotAgentHarness", () => {
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-background",
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
deps.onDeferredCompaction?.({
@@ -1743,7 +1784,7 @@ describe("createCopilotAgentHarness", () => {
: "sdk-sess-replacement";
deps.onSessionEstablished?.({
sdkSessionId,
pooledClient: { key: {} as any, client: {} as any },
pooledClient: { key: TEST_POOL_KEY, client: createMockCopilotClient() },
sessionConfig: TEST_SESSION_CONFIG,
});
if (sdkSessionId === "sdk-sess-background") {
@@ -1804,8 +1845,8 @@ describe("createCopilotAgentHarness", () => {
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
const release = vi.fn(async () => undefined);
pool.release = release;
@@ -1813,8 +1854,8 @@ describe("createCopilotAgentHarness", () => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-compact",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -1909,8 +1950,8 @@ describe("createCopilotAgentHarness", () => {
});
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
const release = vi.fn(async () => undefined);
pool.release = release;
@@ -1918,8 +1959,8 @@ describe("createCopilotAgentHarness", () => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-abort",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -1969,8 +2010,8 @@ describe("createCopilotAgentHarness", () => {
}));
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.acquire = acquire;
pool.release = vi.fn(async () => undefined);
@@ -1986,6 +2027,7 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
provider: { mode: "github-copilot" },
})
.mockReturnValueOnce({
auth: {
@@ -1995,6 +2037,7 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", useLoggedInUser: true },
provider: { mode: "github-copilot" },
})
.mockReturnValueOnce({
auth: {
@@ -2007,13 +2050,14 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
provider: { mode: "github-copilot" },
});
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-token",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2077,8 +2121,8 @@ describe("createCopilotAgentHarness", () => {
}));
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.acquire = acquire;
pool.release = vi.fn(async () => undefined);
@@ -2106,6 +2150,7 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "byok", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home" },
provider: { mode: "byok" },
};
});
const closeByokProxy = vi.fn(async () => undefined);
@@ -2134,8 +2179,8 @@ describe("createCopilotAgentHarness", () => {
},
sdkSessionId: "sdk-sess-byok",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2211,16 +2256,16 @@ describe("createCopilotAgentHarness", () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.acquire = acquire;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-model",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2247,15 +2292,15 @@ describe("createCopilotAgentHarness", () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-login",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2275,6 +2320,7 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
provider: { mode: "github-copilot" },
});
const result = await harness.compact?.(
makeCompactParams({
@@ -2299,16 +2345,16 @@ describe("createCopilotAgentHarness", () => {
});
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-stale",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2334,16 +2380,16 @@ describe("createCopilotAgentHarness", () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-abort",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2388,16 +2434,16 @@ describe("createCopilotAgentHarness", () => {
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cancel",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2438,13 +2484,14 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
provider: { mode: "github-copilot" },
});
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-persisted-token",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession: vi.fn() }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2464,8 +2511,8 @@ describe("createCopilotAgentHarness", () => {
const resumeSession = vi.fn();
const secondPool = makePoolMock();
const secondAcquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
secondPool.acquire = secondAcquire;
const secondHarness = createCopilotAgentHarness({
@@ -2496,6 +2543,7 @@ describe("createCopilotAgentHarness", () => {
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_other" },
provider: { mode: "github-copilot" },
});
const rotatedPool = makePoolMock();
const rotatedAcquire = vi.fn();
@@ -2530,8 +2578,8 @@ describe("createCopilotAgentHarness", () => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-persisted",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession: vi.fn() }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -2542,8 +2590,8 @@ describe("createCopilotAgentHarness", () => {
const resumeSession = vi.fn();
const secondPool = makePoolMock();
const secondAcquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
secondPool.acquire = secondAcquire;
secondPool.release = vi.fn(async () => undefined);
@@ -2579,16 +2627,16 @@ describe("createCopilotAgentHarness", () => {
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-noop",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
key: TEST_POOL_KEY,
client: createMockCopilotClient({ deleteSession: vi.fn(), resumeSession }),
},
sessionConfig: TEST_SESSION_CONFIG,
});
@@ -3,7 +3,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ApplicationCommandType, type APIApplicationCommand } from "discord-api-types/v10";
import {
ApplicationCommandType,
type APIApplicationCommand,
type APIApplicationCommandOption,
} from "discord-api-types/v10";
import { describe, expect, test, vi } from "vitest";
import { DiscordCommandDeployer, testing } from "./command-deploy.js";
import { BaseCommand } from "./commands.js";
@@ -68,7 +72,7 @@ describe("commandsEqual", () => {
name_localizations: null,
description: "Skill name",
description_localizations: null,
} as any,
} as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
@@ -81,7 +85,9 @@ describe("commandsEqual", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [{ type: 3, name: "name", description: "Skill name" } as any],
options: [
{ type: 3, name: "name", description: "Skill name" } as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
name: "skill",
@@ -95,7 +101,9 @@ describe("commandsEqual", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [{ type: 3, name: "name", description: "Skill name" } as any],
options: [
{ type: 3, name: "name", description: "Skill name" } as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
name: "skill",
@@ -154,7 +162,7 @@ describe("commandsEqual", () => {
name: "name",
description: "Skill name",
description_localizations: { "zh-CN": "技能名称。直接输入。" },
} as any,
} as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
+2 -2
View File
@@ -300,7 +300,7 @@ describe("discord group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
@@ -376,7 +376,7 @@ describe("discord group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(
resolveDiscordGroupRequireMention({
+3 -3
View File
@@ -304,10 +304,10 @@ describe("googlechat setup", () => {
});
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
const prompter = {
const prompter = createTestWizardPrompter({
note: vi.fn(async () => {}),
text: vi.fn(async () => "users/123456789"),
};
});
const next = await googlechatSetupWizard.dmPolicy?.promptAllowFrom?.({
cfg: {
@@ -328,7 +328,7 @@ describe("googlechat setup", () => {
},
},
} as OpenClawConfig,
prompter: prompter as any,
prompter,
});
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]);
+2 -1
View File
@@ -1,5 +1,6 @@
// Googlechat tests cover targets plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
import {
@@ -271,7 +272,7 @@ describe("googlechat group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
+2 -1
View File
@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Imessage tests cover targets plugin behavior.
import { describe, expect, it } from "vitest";
import {
@@ -214,7 +215,7 @@ describe("imessage group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false);
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
+8 -3
View File
@@ -27,9 +27,14 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } {
accountId && accountId !== DEFAULT_ACCOUNT_ID
? (lineConfig.accounts?.[accountId] ?? {})
: lineConfig;
const hasToken =
Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile);
const hasSecret = Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile);
const credentials = entry as {
channelAccessToken?: unknown;
channelSecret?: unknown;
secretFile?: unknown;
tokenFile?: unknown;
};
const hasToken = Boolean(credentials.channelAccessToken) || Boolean(credentials.tokenFile);
const hasSecret = Boolean(credentials.channelSecret) || Boolean(credentials.secretFile);
return { tokenSource: hasToken && hasSecret ? "config" : "none" };
},
);
+4 -3
View File
@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Line tests cover group keys plugin behavior.
import { describe, expect, it } from "vitest";
import {
@@ -56,7 +57,7 @@ describe("account-scoped LINE groups", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(resolveLineGroupsConfig(cfg, "work")).toEqual({
"group:g1": { requireMention: false },
@@ -88,7 +89,7 @@ describe("line group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
@@ -117,7 +118,7 @@ describe("line group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
});
+23 -14
View File
@@ -1,6 +1,7 @@
// Line tests cover webhook node plugin behavior.
import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { NextFunction, Request, Response } from "express";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
@@ -68,16 +69,24 @@ function createRuntimeMock(): RuntimeEnvMock {
}
function createMiddlewareRes() {
const status = vi.fn<Response["status"]>();
const json = vi.fn<Response["json"]>();
const res = {
status: vi.fn(),
json: vi.fn(),
status,
json,
headersSent: false,
} as any;
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
} as unknown as Response & { status: typeof status; json: typeof json };
status.mockReturnValue(res);
json.mockReturnValue(res);
return res;
}
function createMiddlewareRequest(
request: Pick<Request, "body" | "headers"> & { rawBody?: string | Buffer },
): Request {
return request as unknown as Request;
}
function createPostWebhookTestHarness(rawBody: string, secret = "secret") {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = createRuntimeMock();
@@ -128,12 +137,12 @@ async function invokeWebhook(params: {
}
}
const req = {
const req = createMiddlewareRequest({
headers,
body: params.body,
} as any;
});
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
await middleware(req, res, vi.fn() as NextFunction);
return { res, onEvents: onEventsMock };
}
@@ -242,14 +251,14 @@ async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signe
});
const rawBodyText =
typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8");
const req = {
const req = createMiddlewareRequest({
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
rawBody: params.rawBody,
body: reqBody,
} as any;
});
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
await middleware(req, res, vi.fn() as NextFunction);
expect(res.status).toHaveBeenCalledWith(200);
expect(onEvents).toHaveBeenCalledTimes(1);
@@ -583,14 +592,14 @@ describe("createLineWebhookMiddleware", () => {
onEvents,
});
const req = {
const req = createMiddlewareRequest({
headers: { "x-line-signature": sign(rawBody, SECRET) },
rawBody,
body: { events: [{ type: "message" }] },
} as any;
});
const res = createMiddlewareRes();
await middleware(req, res, {} as any);
await middleware(req, res, vi.fn() as NextFunction);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
+49 -27
View File
@@ -16,8 +16,11 @@ afterAll(() => {
import { createLlmTaskTool } from "./llm-task-tool.js";
const runEmbeddedAgent = vi.fn(async () => ({
meta: { startedAt: Date.now() },
type LlmTaskApi = Parameters<typeof createLlmTaskTool>[0];
type RunEmbeddedAgent = LlmTaskApi["runtime"]["agent"]["runEmbeddedAgent"];
const runEmbeddedAgent = vi.fn<RunEmbeddedAgent>(async () => ({
meta: { durationMs: 0, startedAt: Date.now() },
payloads: [{ text: "{}" }],
}));
@@ -56,7 +59,7 @@ const normalizeThinkingLevel = vi.fn((raw?: string | null) => {
return undefined;
});
function fakeApi(overrides: any = {}) {
function fakeApi(overrides: Record<string, unknown> = {}): LlmTaskApi {
return {
id: "llm-task",
name: "llm-task",
@@ -85,12 +88,12 @@ function fakeApi(overrides: any = {}) {
logger: { debug() {}, info() {}, warn() {}, error() {} },
registerTool() {},
...overrides,
};
} as unknown as LlmTaskApi;
}
function mockEmbeddedRunJson(payload: unknown) {
(runEmbeddedAgent as any).mockResolvedValueOnce({
meta: {},
runEmbeddedAgent.mockResolvedValueOnce({
meta: { durationMs: 0 },
payloads: [{ text: JSON.stringify(payload) }],
});
}
@@ -98,7 +101,7 @@ function mockEmbeddedRunJson(payload: unknown) {
function resetRunnerMocks() {
runEmbeddedAgent.mockReset();
runEmbeddedAgent.mockImplementation(async () => ({
meta: { startedAt: Date.now() },
meta: { durationMs: 0, startedAt: Date.now() },
payloads: [{ text: "{}" }],
}));
resolveThinkingPolicy.mockClear();
@@ -108,7 +111,26 @@ function resetRunnerMocks() {
async function executeEmbeddedRun(input: Record<string, unknown>) {
const tool = createLlmTaskTool(fakeApi());
await tool.execute("id", input);
return (runEmbeddedAgent as any).mock.calls[0]?.[0];
return firstEmbeddedRunCall();
}
function firstEmbeddedRunCall() {
const call = runEmbeddedAgent.mock.calls[0]?.[0];
if (!call) {
throw new Error("expected embedded agent run");
}
return call;
}
function resultJson(result: unknown): unknown {
if (!result || typeof result !== "object" || !("details" in result)) {
throw new Error("expected tool result details");
}
const details = result.details;
if (!details || typeof details !== "object" || !("json" in details)) {
throw new Error("expected tool result JSON");
}
return details.json;
}
describe("llm-task tool (json-only)", () => {
@@ -117,28 +139,28 @@ describe("llm-task tool (json-only)", () => {
});
it("returns parsed json", async () => {
(runEmbeddedAgent as any).mockResolvedValueOnce({
meta: {},
runEmbeddedAgent.mockResolvedValueOnce({
meta: { durationMs: 0 },
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
});
const tool = createLlmTaskTool(fakeApi());
const res = await tool.execute("id", { prompt: "return foo" });
expect((res as any).details.json).toEqual({ foo: "bar" });
expect(resultJson(res)).toEqual({ foo: "bar" });
});
it("strips fenced json", async () => {
(runEmbeddedAgent as any).mockResolvedValueOnce({
meta: {},
runEmbeddedAgent.mockResolvedValueOnce({
meta: { durationMs: 0 },
payloads: [{ text: '```json\n{"ok":true}\n```' }],
});
const tool = createLlmTaskTool(fakeApi());
const res = await tool.execute("id", { prompt: "return ok" });
expect((res as any).details.json).toEqual({ ok: true });
expect(resultJson(res)).toEqual({ ok: true });
});
it("validates schema", async () => {
(runEmbeddedAgent as any).mockResolvedValueOnce({
meta: {},
runEmbeddedAgent.mockResolvedValueOnce({
meta: { durationMs: 0 },
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
});
const tool = createLlmTaskTool(fakeApi());
@@ -149,18 +171,18 @@ describe("llm-task tool (json-only)", () => {
additionalProperties: false,
};
const res = await tool.execute("id", { prompt: "return foo", schema });
expect((res as any).details.json).toEqual({ foo: "bar" });
expect(resultJson(res)).toEqual({ foo: "bar" });
});
it("validates caller schemas with repeated $id independently across calls", async () => {
const tool = createLlmTaskTool(fakeApi());
(runEmbeddedAgent as any)
runEmbeddedAgent
.mockResolvedValueOnce({
meta: {},
meta: { durationMs: 0 },
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
})
.mockResolvedValueOnce({
meta: {},
meta: { durationMs: 0 },
payloads: [{ text: JSON.stringify({ count: 1 }) }],
});
@@ -198,8 +220,8 @@ describe("llm-task tool (json-only)", () => {
});
it("throws on invalid json", async () => {
(runEmbeddedAgent as any).mockResolvedValueOnce({
meta: {},
runEmbeddedAgent.mockResolvedValueOnce({
meta: { durationMs: 0 },
payloads: [{ text: "not-json" }],
});
const tool = createLlmTaskTool(fakeApi());
@@ -207,8 +229,8 @@ describe("llm-task tool (json-only)", () => {
});
it("throws on schema mismatch", async () => {
(runEmbeddedAgent as any).mockResolvedValueOnce({
meta: {},
runEmbeddedAgent.mockResolvedValueOnce({
meta: { durationMs: 0 },
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
});
const tool = createLlmTaskTool(fakeApi());
@@ -258,7 +280,7 @@ describe("llm-task tool (json-only)", () => {
await tool.execute("id", { prompt: "x", model: "gemini-flash" });
const call = (runEmbeddedAgent as any).mock.calls[0]?.[0];
const call = firstEmbeddedRunCall();
expect(call.provider).toBe("google");
expect(call.model).toBe("gemini-3-flash-preview");
});
@@ -301,7 +323,7 @@ describe("llm-task tool (json-only)", () => {
model: "gpt-5.6-sol",
agentRuntime: "codex",
});
const call = (runEmbeddedAgent as any).mock.calls[0]?.[0];
const call = firstEmbeddedRunCall();
expect(call.thinkLevel).toBe("ultra");
expect(call.config).toBe(config);
expect(call.agentHarnessRuntimeOverride).toBe("codex");
@@ -334,7 +356,7 @@ describe("llm-task tool (json-only)", () => {
model: "gpt-5.6-luna",
agentRuntime: "openclaw",
});
const call = (runEmbeddedAgent as any).mock.calls[0]?.[0];
const call = firstEmbeddedRunCall();
expect(call.thinkLevel).toBe("ultra");
expect(call.config).toBe(config);
expect(call.agentHarnessRuntimeOverride).toBe("openclaw");
+1 -1
View File
@@ -10,7 +10,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
id: "lobster",
name: "lobster",
source: "test",
runtime: { version: "test" } as any,
runtime: { version: "test" } as OpenClawPluginApi["runtime"],
resolvePath: (p) => p,
...overrides,
});
@@ -1,6 +1,7 @@
// Memory Core plugin module implements memory tool manager mock behavior.
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { vi } from "vitest";
import type { getMemorySearchManager } from "./tools.runtime.js";
type SearchImpl = (opts?: {
maxResults?: number;
@@ -20,6 +21,7 @@ type MemoryReadResult = {
nextFrom?: number;
};
type MemoryBackend = "builtin" | "qmd";
type MemoryManagerDebug = Awaited<ReturnType<typeof getMemorySearchManager>>["debug"];
let backend: MemoryBackend = "builtin";
let workspaceDir = "/workspace";
@@ -29,6 +31,7 @@ let getManagerImpl:
| ((params: { cfg?: unknown; agentId?: string; purpose?: string }) => Promise<{
manager?: unknown;
error?: string;
debug?: MemoryManagerDebug;
}>)
| undefined;
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
@@ -101,6 +104,7 @@ export function setMemorySearchManagerImpl(
next: (params: { cfg?: unknown; agentId?: string; purpose?: string }) => Promise<{
manager?: unknown;
error?: string;
debug?: MemoryManagerDebug;
}>,
): void {
getManagerImpl = next;
@@ -60,6 +60,14 @@ const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH;
let transcriptUpdateListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined;
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
type StartupCatchupHarnessInternals = {
syncArchiveFiles(params: { needsFullReindex: boolean }): Promise<void>;
updateSessionDelta(sessionFile: string): Promise<{
pendingBytes: number;
pendingLines: number;
pendingMessages: number;
}>;
};
function setStartupStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
@@ -407,7 +415,9 @@ describe("session startup catch-up", () => {
});
try {
await (harness as any).syncArchiveFiles({ needsFullReindex: true });
await (harness as unknown as StartupCatchupHarnessInternals).syncArchiveFiles({
needsFullReindex: true,
});
expect(attempts).toBe(2);
} finally {
openSpy.mockRestore();
@@ -528,7 +538,9 @@ describe("session startup catch-up", () => {
});
try {
const delta = await (harness as any).updateSessionDelta(session.filePath);
const delta = await (harness as unknown as StartupCatchupHarnessInternals).updateSessionDelta(
session.filePath,
);
expect(delta).toMatchObject({
pendingBytes: session.size,
pendingMessages: 1,
+37 -40
View File
@@ -699,46 +699,43 @@ describe("memory_search unavailable payloads", () => {
});
it("includes manager acquisition timing and cache-state debug payload", async () => {
setMemorySearchManagerImpl(
async () =>
({
manager: {
search: vi.fn(async () => {
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
];
}),
readFile: vi.fn(),
status: vi.fn(() => ({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/workspace",
dbPath: "/tmp/workspace/index.sqlite",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
},
debug: {
managerMs: 17,
managerCacheState: "cached-full-hit",
},
}) as any,
);
setMemorySearchManagerImpl(async () => ({
manager: {
search: vi.fn(async () => {
return [
{
path: "MEMORY.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "ramen",
source: "memory",
},
];
}),
readFile: vi.fn(),
status: vi.fn(() => ({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp/workspace",
dbPath: "/tmp/workspace/index.sqlite",
sources: ["memory"],
sourceCounts: [{ source: "memory", files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
},
debug: {
managerMs: 17,
managerCacheState: "cached-full-hit",
},
}));
setMemorySearchImpl(async () => [
{
path: "MEMORY.md",
+29 -25
View File
@@ -87,6 +87,10 @@ function createRuntimeLoader(
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
function registerTestPlugin(plugin: { register: (api: never) => void }, api: unknown): void {
plugin.register(api as never);
}
function firstMockArg(source: MockCallSource, label: string, argIndex = 0) {
const [call] = source.mock.calls;
if (!call) {
@@ -312,7 +316,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
memoryPlugin.register(mockApi as any);
registerTestPlugin(memoryPlugin, mockApi);
const service = firstObjectArg(registerService as unknown as MockCallSource, "service");
expect(service.id).toBe("memory-lancedb");
expect(service.start).toBeTypeOf("function");
@@ -355,7 +359,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
memoryPlugin.register(mockApi as any);
registerTestPlugin(memoryPlugin, mockApi);
expectHookRegistered(on, "before_prompt_build");
expectHookNotRegistered(on, "before_agent_start");
@@ -396,7 +400,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
memoryPlugin.register(mockApi as any);
registerTestPlugin(memoryPlugin, mockApi);
const capability = firstObjectArg(
registerMemoryCapabilityLocal as unknown as MockCallSource,
"memory capability",
@@ -491,7 +495,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
memoryPlugin.register(mockApi as any);
registerTestPlugin(memoryPlugin, mockApi);
expect(registerMemoryCapabilityForPlugin).toHaveBeenCalledOnce();
expect(
@@ -607,7 +611,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const recallTool = registerTool.mock.calls
.map(([tool]) => tool)
.find((tool) => tool.name === "memory_recall");
@@ -696,7 +700,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
if (!recallTool) {
throw new Error("memory_recall tool was not registered");
@@ -793,7 +797,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
if (!recallTool) {
throw new Error("memory_recall tool was not registered");
@@ -882,7 +886,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
if (!recallTool) {
throw new Error("memory_recall tool was not registered");
@@ -973,7 +977,7 @@ describe("memory plugin e2e", () => {
};
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const registrar = firstMockArg(registerCli as unknown as MockCallSource, "cli registrar");
const program = new Command();
(registrar as (params: { program: Command }) => void)({ program });
@@ -1018,7 +1022,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
memoryPlugin.register(mockApi as any);
registerTestPlugin(memoryPlugin, mockApi);
const beforePromptBuild = on.mock.calls.find(
([hookName]) => hookName === "before_prompt_build",
@@ -1060,7 +1064,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
memoryPlugin.register(mockApi as any);
registerTestPlugin(memoryPlugin, mockApi);
expectHookRegistered(on, "before_prompt_build");
const agentEnd = on.mock.calls.find(([hookName]) => hookName === "agent_end")?.[1];
@@ -1143,7 +1147,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const beforePromptBuild = on.mock.calls.find(
([hookName]) => hookName === "before_prompt_build",
@@ -1248,7 +1252,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const beforePromptBuild = on.mock.calls.find(
([hookName]) => hookName === "before_prompt_build",
@@ -1414,7 +1418,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
configFile = {
plugins: {
@@ -1543,7 +1547,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
configFile = {
plugins: {
@@ -1668,7 +1672,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
configFile = {
plugins: {
@@ -1765,7 +1769,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const agentEnd = on.mock.calls.find(([hookName]) => hookName === "agent_end")?.[1];
expect(agentEnd).toBeTypeOf("function");
@@ -1893,7 +1897,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
configFile = {
plugins: {
@@ -2027,7 +2031,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
configFile = {
plugins: {
@@ -2154,7 +2158,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
configFile = {
plugins: {
@@ -2257,7 +2261,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const agentEnd = on.mock.calls.find(([hookName]) => hookName === "agent_end")?.[1];
const sessionEnd = on.mock.calls.find(([hookName]) => hookName === "session_end")?.[1];
@@ -2510,7 +2514,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
memoryPluginItem.register(mockApi as any);
registerTestPlugin(memoryPluginItem, mockApi);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
if (!recallTool) {
throw new Error("memory_recall tool was not registered");
@@ -2611,7 +2615,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
if (!recallTool) {
throw new Error("memory_recall tool was not registered");
@@ -2893,7 +2897,7 @@ describe("memory plugin e2e", () => {
resolvePath: (filePath: string) => filePath,
};
dynamicMemoryPlugin.register(mockApi as any);
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool;
if (!storeTool) {
throw new Error("memory_store tool was not registered");
@@ -3028,7 +3032,7 @@ describe("memory plugin e2e", () => {
resolvePath: (p: string) => p,
};
memoryPluginLocal.register(mockApi as any);
registerTestPlugin(memoryPluginLocal, mockApi);
const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool;
if (!forgetTool) {
throw new Error("expected memory_forget tool registration");
@@ -62,7 +62,7 @@ describeLive("memory plugin live tests", () => {
};
// Register plugin
memoryPlugin.register(mockApi as any);
memoryPlugin.register(mockApi as unknown as Parameters<typeof memoryPlugin.register>[0]);
// Check registration
expect(registeredTools.length).toBe(3);
+29 -12
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MSTeamsConfig } from "../runtime-api.js";
import { readAccessToken } from "./token-response.js";
import {
hasConfiguredMSTeamsCredentials,
@@ -73,12 +74,16 @@ describe("token secret credentials", () => {
afterEach(restoreEnv);
it("returns true when appId + appPassword + tenantId are provided in config", () => {
const cfg = { appId: "app-id", appPassword: "app-pw", tenantId: "tenant-id" } as any;
const cfg = {
appId: "app-id",
appPassword: "app-pw",
tenantId: "tenant-id",
} satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(true);
});
it("returns false when appPassword is missing", () => {
const cfg = { appId: "app-id", tenantId: "tenant-id" } as any;
const cfg = { appId: "app-id", tenantId: "tenant-id" } satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(false);
});
@@ -87,7 +92,11 @@ describe("token secret credentials", () => {
});
it("resolves secret credentials from config", () => {
const cfg = { appId: "app-id", appPassword: "app-pw", tenantId: "tenant-id" } as any;
const cfg = {
appId: "app-id",
appPassword: "app-pw",
tenantId: "tenant-id",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "secret",
@@ -111,7 +120,7 @@ describe("token secret credentials", () => {
});
it("returns undefined when appPassword is missing", () => {
const cfg = { appId: "app-id", tenantId: "tenant-id" } as any;
const cfg = { appId: "app-id", tenantId: "tenant-id" } satisfies MSTeamsConfig;
expect(resolveMSTeamsCredentials(cfg)).toBeUndefined();
});
});
@@ -126,12 +135,16 @@ describe("token federated credentials (certificate)", () => {
tenantId: "tenant-id",
authType: "federated",
certificatePath: "/cert.pem",
} as any;
} satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(true);
});
it("hasConfigured returns false when neither cert nor MI is provided", () => {
const cfg = { appId: "app-id", tenantId: "tenant-id", authType: "federated" } as any;
const cfg = {
appId: "app-id",
tenantId: "tenant-id",
authType: "federated",
} satisfies MSTeamsConfig;
expect(hasConfiguredMSTeamsCredentials(cfg)).toBe(false);
});
@@ -142,7 +155,7 @@ describe("token federated credentials (certificate)", () => {
authType: "federated",
certificatePath: "/cert.pem",
certificateThumbprint: "AABBCCDD",
} as any;
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
@@ -185,7 +198,7 @@ describe("token federated credentials (managed identity)", () => {
authType: "federated",
useManagedIdentity: true,
managedIdentityClientId: "mi-client-id",
} as any;
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
@@ -204,7 +217,7 @@ describe("token federated credentials (managed identity)", () => {
tenantId: "tenant-id",
authType: "federated",
useManagedIdentity: true,
} as any;
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
@@ -233,7 +246,7 @@ describe("token federated credentials (managed identity)", () => {
authType: "federated",
certificatePath: "/cert.pem",
useManagedIdentity: false,
} as any;
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "federated",
@@ -252,7 +265,11 @@ describe("token backward compatibility", () => {
afterEach(restoreEnv);
it("defaults to secret when authType is absent", () => {
const cfg = { appId: "app-id", appPassword: "pw", tenantId: "tenant-id" } as any;
const cfg = {
appId: "app-id",
appPassword: "pw",
tenantId: "tenant-id",
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "secret",
@@ -268,7 +285,7 @@ describe("token backward compatibility", () => {
appPassword: "pw",
tenantId: "tenant-id",
authType: "secret",
} as any;
} satisfies MSTeamsConfig;
const result = resolveMSTeamsCredentials(cfg);
expect(result).toEqual({
type: "secret",
+144 -86
View File
@@ -9,6 +9,19 @@ import { createOpencodeGoStalledStreamWrapper } from "./stream-termination.js";
type AnyEvent = AssistantMessageEvent;
type StreamLike = AssistantMessageEventStreamContract;
type ProviderStreamFn = Parameters<typeof createOpencodeGoStalledStreamWrapper>[0];
type ProviderModel = Parameters<ProviderStreamFn>[0];
type ProviderContext = Parameters<ProviderStreamFn>[1];
type ProviderCallOptions = Parameters<ProviderStreamFn>[2];
type ErrorEvent = Extract<AnyEvent, { type: "error" }>;
function asProviderEvent(event: unknown): AnyEvent {
return event as AnyEvent;
}
function asProviderModel(model: unknown): ProviderModel {
return model as ProviderModel;
}
interface FakeStreamController {
emit(event: AnyEvent): void;
@@ -125,13 +138,17 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -152,13 +169,15 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "hi" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hi",
partial,
} as any);
controller.emit(asProviderEvent({ type: "start", partial }));
controller.emit(
asProviderEvent({
type: "text_delta",
contentIndex: 0,
delta: "hi",
partial,
}),
);
// Advance wall clock beyond idleTimeoutMs without any new progress.
await vi.advanceTimersByTimeAsync(6_000);
@@ -169,10 +188,10 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
// And it pushed a terminal error event to the downstream consumer.
const terminal = received.find(
(event) => event.type === "error" && (event as any).reason === "error",
(event): event is ErrorEvent => event.type === "error" && event.reason === "error",
);
expect(terminal).toBeDefined();
expect((terminal as any)?.error).toMatchObject({
expect(terminal?.error).toMatchObject({
stopReason: "error",
errorMessage: "opencode-go stream timed out after provider-owned SSE boundary stalled",
});
@@ -195,14 +214,18 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -236,14 +259,18 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -262,20 +289,22 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit(asProviderEvent({ type: "start", partial }));
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial: {
...partial,
content: [{ type: "text", text: "hello" }],
},
} as any);
controller.emit(
asProviderEvent({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial: {
...partial,
content: [{ type: "text", text: "hello" }],
},
}),
);
controller.emit({
type: "done",
reason: "stop",
@@ -284,7 +313,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
},
} as any);
} as AnyEvent);
await consumer;
expect(abortCalled).toBe(false);
@@ -305,14 +334,18 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -331,8 +364,8 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "text_start", contentIndex: 0, partial } as any);
controller.emit(asProviderEvent({ type: "start", partial }));
controller.emit(asProviderEvent({ type: "text_start", contentIndex: 0, partial }));
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
@@ -347,8 +380,8 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
contentIndex: 0,
delta: "hello",
partial: message,
} as any);
controller.emit({ type: "done", reason: "stop", message } as any);
} as AnyEvent);
controller.emit({ type: "done", reason: "stop", message } as AnyEvent);
await consumer;
expect(abortCalled).toBe(false);
@@ -369,7 +402,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 5_000,
@@ -377,9 +410,13 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 10_000 } as any,
{} as any,
{} as any,
asProviderModel({
provider: "opencode-go",
id: "deepseek-v4-flash",
requestTimeoutMs: 10_000,
}),
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
@@ -398,7 +435,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "slow" }],
stopReason: undefined,
};
controller.emit({ type: "start", partial } as any);
controller.emit(asProviderEvent({ type: "start", partial }));
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(false);
@@ -413,7 +450,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
const underlying = vi.fn((_model, _context, _options) => baseStream);
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 120_000,
firstEventTimeoutMs: 300_000,
@@ -421,9 +458,9 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ firstEventTimeoutMs: 30_000 } as any,
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{ firstEventTimeoutMs: 30_000 } as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
@@ -459,7 +496,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
firstEventTimeoutMs: 10_000,
@@ -467,9 +504,13 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash", requestTimeoutMs: 2_000 } as any,
{} as any,
{} as any,
asProviderModel({
provider: "opencode-go",
id: "deepseek-v4-flash",
requestTimeoutMs: 2_000,
}),
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
@@ -503,13 +544,17 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -528,9 +573,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
expect(capturedSignals).toHaveLength(1);
expect(abortCalled).toBe(true);
expect(getReturnCalls()).toBe(1);
expect(
received.some((event) => event.type === "error" && (event as any).reason === "error"),
).toBe(true);
expect(received.some((event) => event.type === "error" && event.reason === "error")).toBe(true);
await consumer;
});
@@ -549,13 +592,17 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
});
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -572,9 +619,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
await vi.advanceTimersByTimeAsync(6_000);
expect(abortCalled).toBe(true);
expect(
received.some((event) => event.type === "error" && (event as any).reason === "error"),
).toBe(true);
expect(received.some((event) => event.type === "error" && event.reason === "error")).toBe(true);
await consumer;
});
@@ -593,16 +638,16 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ signal: new AbortController().signal } as any,
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{ signal: new AbortController().signal } as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
@@ -633,16 +678,19 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
const { stream: baseStream, controller } = createFakeBaseStream();
try {
const wrapper = createOpencodeGoStalledStreamWrapper(vi.fn(() => baseStream) as any, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const wrapper = createOpencodeGoStalledStreamWrapper(
vi.fn(() => baseStream) as ProviderStreamFn,
{
provider: "opencode-go",
idleTimeoutMs: 5_000,
},
);
const downstream = await Promise.resolve(
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as any,
{} as any,
{ signal: sourceController.signal } as any,
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{ signal: sourceController.signal } as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
@@ -662,8 +710,8 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "done" }],
stopReason: "stop",
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "done", reason: "stop", message: partial } as any);
controller.emit({ type: "start", partial } as AnyEvent);
controller.emit({ type: "done", reason: "stop", message: partial } as AnyEvent);
await consumer;
expect(received.some((event) => event.type === "done")).toBe(true);
@@ -695,13 +743,17 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
return baseStream;
});
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs: 5_000,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "deepseek-v4-flash" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "deepseek-v4-flash" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -720,13 +772,13 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "hello" }],
stopReason: "stop",
};
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "start", partial } as AnyEvent);
controller.emit({
type: "text_delta",
contentIndex: 0,
delta: "hello",
partial,
} as any);
} as AnyEvent);
// Simulate a delayed final chunk after a short (sub-timeout) quiet gap.
await vi.advanceTimersByTimeAsync(2_000);
@@ -736,7 +788,7 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
type: "done",
reason: "stop",
message: partial,
} as any);
} as AnyEvent);
// Advance well past the idle timeout — wrapper should NOT have fired.
await vi.advanceTimersByTimeAsync(10_000);
@@ -770,13 +822,17 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
});
const idleTimeoutMs = 5_000;
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as any, {
const wrapper = createOpencodeGoStalledStreamWrapper(underlying as ProviderStreamFn, {
provider: "opencode-go",
idleTimeoutMs,
});
const downstream = await Promise.resolve(
wrapper({ provider: "opencode-go", id: "glm-4.6" } as any, {} as any, {} as any),
wrapper(
{ provider: "opencode-go", id: "glm-4.6" } as ProviderModel,
{} as ProviderContext,
{} as ProviderCallOptions,
),
);
expect(downstream).toBeDefined();
if (!downstream) {
@@ -793,31 +849,33 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
const partial = { role: "assistant", content: [{ type: "text", text: "x" }] };
// Provider starts producing a tool-call turn. The last *delta* arms the idle timer.
controller.emit({ type: "start", partial } as any);
controller.emit({ type: "start", partial } as AnyEvent);
controller.emit({
type: "toolcall_delta",
contentIndex: 0,
delta: "{",
partial,
} as any);
} as AnyEvent);
await vi.advanceTimersByTimeAsync(0);
// The model finalizes the tool call and deliberates on the next one,
// emitting real block-boundary events that prove the SSE socket is alive.
// Each gap is < idleTimeoutMs, so a liveness-aware watchdog must stay armed.
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_end",
contentIndex: 0,
toolCall: { name: "f", arguments: "{}" },
partial,
} as any);
controller.emit(
asProviderEvent({
type: "toolcall_end",
contentIndex: 0,
toolCall: { name: "f", arguments: "{}" },
partial,
}),
);
await vi.advanceTimersByTimeAsync(3_000);
controller.emit({
type: "toolcall_start",
contentIndex: 1,
partial,
} as any);
} as AnyEvent);
// Advance to 5s after the last delta, but only 2s after the last
// boundary event. The idle timer should have been re-armed by the
@@ -833,14 +891,14 @@ describe("createOpencodeGoStalledStreamWrapper", () => {
content: [{ type: "text", text: "final answer" }],
stopReason: "stop",
},
} as any);
} as AnyEvent);
controller.end();
await vi.advanceTimersByTimeAsync(0);
await consumer;
const hasDone = received.some((e) => e.type === "done");
const hasStalledError = received.some(
(e) => e.type === "error" && (e as any).error?.stopReason === "error",
(e) => e.type === "error" && e.error?.stopReason === "error",
);
expect(abortCalled).toBe(false);
+14 -16
View File
@@ -11,30 +11,28 @@ import {
import * as containerClientModule from "./client-container.js";
import * as nativeClientModule from "./client.js";
const mockNativeCheck = vi.fn();
const mockNativeRpcRequest = vi.fn();
const mockNativeStreamEvents = vi.fn();
const mockContainerCheck = vi.fn();
const mockContainerRpcRequest = vi.fn();
const mockContainerFetchAttachment = vi.fn();
const mockStreamContainerEvents = vi.fn();
const mockNativeCheck = vi.fn<typeof nativeClientModule.signalCheck>();
const mockNativeRpcRequest = vi.fn<typeof nativeClientModule.signalRpcRequest>();
const mockNativeStreamEvents = vi.fn<typeof nativeClientModule.streamSignalEvents>();
const mockContainerCheck = vi.fn<typeof containerClientModule.containerCheck>();
const mockContainerRpcRequest = vi.fn<typeof containerClientModule.containerRpcRequest>();
const mockContainerFetchAttachment = vi.fn<typeof containerClientModule.containerFetchAttachment>();
const mockStreamContainerEvents = vi.fn<typeof containerClientModule.streamContainerEvents>();
let currentApiMode: SignalApiMode = "auto";
beforeEach(() => {
vi.spyOn(nativeClientModule, "signalCheck").mockImplementation(mockNativeCheck as any);
vi.spyOn(nativeClientModule, "signalRpcRequest").mockImplementation(mockNativeRpcRequest as any);
vi.spyOn(nativeClientModule, "streamSignalEvents").mockImplementation(
mockNativeStreamEvents as any,
);
vi.spyOn(containerClientModule, "containerCheck").mockImplementation(mockContainerCheck as any);
vi.spyOn(nativeClientModule, "signalCheck").mockImplementation(mockNativeCheck);
vi.spyOn(nativeClientModule, "signalRpcRequest").mockImplementation(mockNativeRpcRequest);
vi.spyOn(nativeClientModule, "streamSignalEvents").mockImplementation(mockNativeStreamEvents);
vi.spyOn(containerClientModule, "containerCheck").mockImplementation(mockContainerCheck);
vi.spyOn(containerClientModule, "containerRpcRequest").mockImplementation(
mockContainerRpcRequest as any,
mockContainerRpcRequest,
);
vi.spyOn(containerClientModule, "containerFetchAttachment").mockImplementation(
mockContainerFetchAttachment as any,
mockContainerFetchAttachment,
);
vi.spyOn(containerClientModule, "streamContainerEvents").mockImplementation(
mockStreamContainerEvents as any,
mockStreamContainerEvents,
);
});
@@ -198,7 +198,7 @@ describe("signal createSignalEventHandler inbound context", () => {
it("sets ReplyToId from the inbound Signal timestamp", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
cfg: { messages: { inbound: { debounceMs: 0 } } } as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -246,7 +246,7 @@ describe("signal createSignalEventHandler inbound context", () => {
])("falls back to $name timestamp for native reply metadata", async ({ envelope }) => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
cfg: { messages: { inbound: { debounceMs: 0 } } } as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -269,7 +269,7 @@ describe("signal createSignalEventHandler inbound context", () => {
it("uses editMessage.targetSentTimestamp as the native reply target", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
cfg: { messages: { inbound: { debounceMs: 0 } } } as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -319,7 +319,7 @@ describe("signal createSignalEventHandler inbound context", () => {
cfg: {
messages: { inbound: { debounceMs: 10 } },
channels: { signal: { replyToMode: "batched" } },
} as any,
} as OpenClawConfig,
deliverReplies: deliverRepliesMock,
historyLimit: 0,
}),
@@ -375,7 +375,7 @@ describe("signal createSignalEventHandler inbound context", () => {
session: { dmScope: "per-channel-peer" },
messages: { inbound: { debounceMs: 0 } },
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -416,7 +416,7 @@ describe("signal createSignalEventHandler inbound context", () => {
it("keeps direct chat text in BodyForAgent while Body remains the legacy envelope", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
cfg: { messages: { inbound: { debounceMs: 0 } } } as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -475,7 +475,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -551,7 +551,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -626,7 +626,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -692,7 +692,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -758,7 +758,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -814,7 +814,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
sendReadReceipts: true,
historyLimit: 0,
}),
@@ -866,7 +866,7 @@ describe("signal createSignalEventHandler inbound context", () => {
statusReactions: { enabled: true },
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -900,7 +900,7 @@ describe("signal createSignalEventHandler inbound context", () => {
statusReactions: { enabled: true },
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -929,7 +929,7 @@ describe("signal createSignalEventHandler inbound context", () => {
cfg: {
messages: { inbound: { debounceMs: 0 } },
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -969,7 +969,7 @@ describe("signal createSignalEventHandler inbound context", () => {
reactionLevel: "off",
},
},
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1026,7 +1026,7 @@ describe("signal createSignalEventHandler inbound context", () => {
reactionLevel: "ack",
},
},
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1080,7 +1080,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
},
} as any,
} as OpenClawConfig,
accountId: "work",
historyLimit: 0,
}),
@@ -1114,7 +1114,7 @@ describe("signal createSignalEventHandler inbound context", () => {
statusReactions: { enabled: true },
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1162,7 +1162,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1219,7 +1219,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1272,7 +1272,7 @@ describe("signal createSignalEventHandler inbound context", () => {
groups: { "*": { requireMention: false } },
},
},
} as any,
} as OpenClawConfig,
groupPolicy: "allowlist",
groupAllowFrom: ["g1"],
historyLimit: 0,
@@ -1328,7 +1328,7 @@ describe("signal createSignalEventHandler inbound context", () => {
groups: { "*": { requireMention: true } },
},
},
} as any,
} as OpenClawConfig,
groupPolicy: "allowlist",
groupAllowFrom: ["g1"],
historyLimit: 0,
@@ -1380,7 +1380,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1433,7 +1433,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1479,7 +1479,7 @@ describe("signal createSignalEventHandler inbound context", () => {
},
},
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
} as OpenClawConfig,
historyLimit: 0,
}),
);
@@ -1523,7 +1523,7 @@ describe("signal createSignalEventHandler inbound context", () => {
]);
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
cfg: { messages: { inbound: { debounceMs: 0 } } } as OpenClawConfig,
groupHistories,
historyLimit: 5,
}),
@@ -1823,7 +1823,7 @@ describe("signal createSignalEventHandler inbound context", () => {
};
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: cfg as any,
cfg: cfg as OpenClawConfig,
dmPolicy: "allowlist",
allowFrom: [],
reactionMode: "all",
@@ -1,5 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Signal tests cover retry behavior for reply session initialization conflicts.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SignalEventHandlerDeps } from "./event-handler.types.js";
const [
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
@@ -240,7 +242,7 @@ describe("signal reply session init conflict retry", () => {
error: (msg: string) => {
errorLogs.push(msg);
},
} as any,
} as SignalEventHandlerDeps["runtime"],
}),
);
@@ -313,7 +315,7 @@ describe("signal reply session init conflict retry", () => {
createBaseSignalEventHandlerDeps({
cfg: {
messages: { inbound: { debounceMs: 10 } },
} as any,
} as OpenClawConfig,
}),
);
@@ -5,7 +5,7 @@ export function createBaseSignalEventHandlerDeps(
overrides: Partial<SignalEventHandlerDeps> = {},
): SignalEventHandlerDeps {
return {
runtime: { log: () => {}, error: () => {} } as any,
runtime: { log: () => {}, error: () => {} } as SignalEventHandlerDeps["runtime"],
cfg: {},
baseUrl: "http://localhost",
accountId: "default",
+2 -1
View File
@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Slack tests cover group policy plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
@@ -22,7 +23,7 @@ const cfg = {
},
},
},
} as any;
} as OpenClawConfig;
describe("slack group policy", () => {
it("uses matched channel requireMention and wildcard fallback", () => {
@@ -114,7 +114,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
return slackCtx;
}
@@ -292,7 +292,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
ctx.accountId = "soltea";
ctx.allowFrom = ["*"];
ctx.dmPolicy = "open";
ctx.resolveUserName = async () => ({ name: "External User" }) as any;
ctx.resolveUserName = async () => ({ name: "External User" });
const prepared = await prepareSlackMessage({
ctx,
@@ -796,7 +796,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
session: { dmScope: "main" },
} as OpenClawConfig,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
// Simulate API returning correct type for DM channel
slackCtx.resolveChannelName = async () => ({ name: undefined, type: "im" as const });
return slackCtx;
@@ -817,7 +817,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
options?: { includeFromCheck?: boolean },
) {
assertPrepared(prepared);
expectInboundContextContract(prepared.ctxPayload as any);
expectInboundContextContract(prepared.ctxPayload);
expect(prepared.isDirectMessage).toBe(true);
expect(prepared.route.sessionKey).toBe("agent:main:main");
expect(prepared.ctxPayload.ChatType).toBe("direct");
@@ -851,7 +851,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
? {}
: { defaultRequireMention: params.defaultRequireMention }),
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
if (params?.asChannel) {
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
}
@@ -870,7 +870,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
const prepared = await prepareWithDefaultCtx(message);
assertPrepared(prepared);
expectInboundContextContract(prepared.ctxPayload as any);
expectInboundContextContract(prepared.ctxPayload);
expect(prepared.ctxPayload.GroupSpace).toBe("T1");
});
@@ -885,7 +885,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
channel: "D123",
@@ -932,7 +932,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
@@ -968,7 +968,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
reactions: { add: addReaction },
} as unknown as App["client"],
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
@@ -1010,7 +1010,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
} as OpenClawConfig,
defaultRequireMention: false,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
slackCtx.ackReactionScope = "group-all";
@@ -1048,10 +1048,10 @@ describe("slack prepareSlackMessage inbound contract", () => {
},
},
} as OpenClawConfig,
appClient: { reactions: { add: reactionAdd } } as any,
appClient: { reactions: { add: reactionAdd } } as unknown as App["client"],
defaultRequireMention: false,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
slackCtx.ackReactionScope = "all";
@@ -1093,7 +1093,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
} as OpenClawConfig,
defaultRequireMention: false,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
@@ -1229,7 +1229,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
} as OpenClawConfig,
defaultRequireMention: false,
});
slackCtx.resolveUserName = async () => ({ name: "Bot" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Bot" });
const account = createSlackAccount({ allowBots: true });
const message = createSlackMessage({
@@ -1559,7 +1559,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
} as OpenClawConfig,
defaultRequireMention: false,
});
slackCtx.resolveUserName = async () => ({ name: "Bot" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Bot" });
const prepared = await prepareMessageWith(
slackCtx,
@@ -1799,7 +1799,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
C123: { systemPrompt: "Config prompt" },
},
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const channelInfo = {
name: "general",
type: "channel" as const,
@@ -2793,7 +2793,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const message = createSlackMessage({ ts: "500.000" });
const prepared = await prepareMessageWith(
@@ -2816,7 +2816,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const prepared = await prepareMessageWith(
slackCtx,
@@ -2856,7 +2856,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const prepared = await prepareMessageWith(
slackCtx,
@@ -2919,7 +2919,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
appClient: { conversations: { replies } } as unknown as App["client"],
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const prepared = await prepareMessageWith(
slackCtx,
@@ -2949,7 +2949,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const prepared = await prepareMessageWith(
slackCtx,
@@ -4365,7 +4365,7 @@ describe("prepareSlackMessage sender prefix", () => {
channels: {},
slashCommand: { command: "/openclaw", enabled: true },
});
ctx.resolveUserName = async (id: string) => ({ name: id === "U1" ? "Alice" : "Bek" }) as any;
ctx.resolveUserName = async (id: string) => ({ name: id === "U1" ? "Alice" : "Bek" });
const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001");
@@ -4382,8 +4382,9 @@ describe("prepareSlackMessage sender prefix", () => {
channels: {},
slashCommand: { command: "/openclaw", enabled: true },
});
ctx.resolveUserName = async (id: string) =>
({ name: id === "U1" ? "Alice" : undefined }) as any;
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : undefined,
});
const result = await prepareSenderPrefixMessage(ctx, "<@BOT> hello", "1700000000.0001");
@@ -4521,7 +4522,7 @@ describe("slack thread.requireExplicitMention", () => {
} as OpenClawConfig,
threadRequireExplicitMention: requireExplicitMention,
});
ctx.resolveUserName = async () => ({ name: "Alice" }) as any;
ctx.resolveUserName = async () => ({ name: "Alice" });
return ctx;
}
@@ -3,6 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { SlackMessageEvent } from "../types.js";
import { createSlackThreadTsResolver } from "./thread-resolution.js";
type SlackThreadClient = Parameters<typeof createSlackThreadTsResolver>[0]["client"];
function createThreadClient(history: ReturnType<typeof vi.fn>): SlackThreadClient {
return { conversations: { history } } as unknown as SlackThreadClient;
}
describe("createSlackThreadTsResolver", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -22,7 +28,7 @@ describe("createSlackThreadTsResolver", () => {
messages: [{ ts: "1", thread_ts: "9" }],
});
const resolver = createSlackThreadTsResolver({
client: { conversations: { history: historyMock } } as any,
client: createThreadClient(historyMock),
cacheTtlMs: 60_000,
maxSize: 5,
});
@@ -42,7 +48,7 @@ describe("createSlackThreadTsResolver", () => {
messages: [{ ts: "1" }],
});
const resolver = createSlackThreadTsResolver({
client: { conversations: { history: historyMock } } as any,
client: createThreadClient(historyMock),
cacheTtlMs: 60_000,
maxSize: 5,
});
+149 -127
View File
@@ -19,6 +19,12 @@ import {
shouldUseTelegramDmThreadSession,
} from "./helpers.js";
type TelegramMessage = Parameters<typeof normalizeForwardedContext>[0];
function asMalformedTelegramMessage(message: unknown): TelegramMessage {
return message as TelegramMessage;
}
describe("resolveTelegramForumThreadId", () => {
it.each([
{ isForum: false, messageThreadId: 42 },
@@ -332,7 +338,7 @@ describe("normalizeForwardedContext", () => {
sender_user: { first_name: "Ada", last_name: "Lovelace", username: "ada", id: 42 },
date: 123,
},
} as any);
} as TelegramMessage);
expect(ctx?.from).toBe("Ada Lovelace (@ada)");
expect(ctx?.fromType).toBe("user");
expect(ctx?.fromId).toBe("42");
@@ -344,7 +350,7 @@ describe("normalizeForwardedContext", () => {
it("handles hidden forward_origin names", () => {
const ctx = normalizeForwardedContext({
forward_origin: { type: "hidden_user", sender_user_name: "Hidden Name", date: 456 },
} as any);
} as TelegramMessage);
expect(ctx?.from).toBe("Hidden Name");
expect(ctx?.fromType).toBe("hidden_user");
expect(ctx?.fromTitle).toBe("Hidden Name");
@@ -365,7 +371,7 @@ describe("normalizeForwardedContext", () => {
author_signature: "Editor",
message_id: 42,
},
} as any);
} as TelegramMessage);
expect(ctx?.from).toBe("Tech News (Editor)");
expect(ctx?.fromType).toBe("channel");
expect(ctx?.fromId).toBe("-1001234");
@@ -389,7 +395,7 @@ describe("normalizeForwardedContext", () => {
date: 600,
author_signature: "Admin",
},
} as any);
} as TelegramMessage);
expect(ctx?.from).toBe("Discussion Group (Admin)");
expect(ctx?.fromType).toBe("chat");
expect(ctx?.fromId).toBe("-1005678");
@@ -408,7 +414,7 @@ describe("normalizeForwardedContext", () => {
author_signature: "New Sig",
message_id: 1,
},
} as any);
} as TelegramMessage);
expect(ctx?.fromSignature).toBe("New Sig");
expect(ctx?.from).toBe("My Channel (New Sig)");
});
@@ -422,7 +428,7 @@ describe("normalizeForwardedContext", () => {
author_signature: " ",
message_id: 1,
},
} as any);
} as TelegramMessage);
expect(ctx?.fromSignature).toBeUndefined();
expect(ctx?.from).toBe("Updates");
});
@@ -435,7 +441,7 @@ describe("normalizeForwardedContext", () => {
date: 900,
message_id: 1,
},
} as any);
} as TelegramMessage);
expect(ctx?.from).toBe("News");
expect(ctx?.fromSignature).toBeUndefined();
expect(ctx?.fromChatType).toBe("channel");
@@ -444,27 +450,31 @@ describe("normalizeForwardedContext", () => {
describe("describeReplyTarget", () => {
it("returns null when no reply_to_message", () => {
const result = describeReplyTarget({
message_id: 1,
date: 1000,
chat: { id: 1, type: "private" },
} as any);
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 1,
date: 1000,
chat: { id: 1, type: "private" },
}),
);
expect(result).toBeNull();
});
it("extracts basic reply info", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
reply_to_message: {
message_id: 1,
date: 900,
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
text: "Original message",
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
text: "Original message",
from: { id: 42, first_name: "Alice", is_bot: false },
},
}),
);
expect(result?.body).toBe("Original message");
expect(result?.sender).toBe("Alice");
expect(result?.id).toBe("1");
@@ -473,36 +483,40 @@ describe("describeReplyTarget", () => {
});
it("handles non-string reply text gracefully (issue #27201)", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
// Simulate edge case where text is an unexpected non-string value
text: { some: "object" },
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private", first_name: "Test" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private", first_name: "Test" },
// Simulate edge case where text is an unexpected non-string value
text: { some: "object" },
from: { id: 42, first_name: "Alice", is_bot: false },
},
}),
);
expect(result).toBeNull();
});
it("falls back to caption when reply text is malformed", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
text: { some: "object" },
caption: "Caption body",
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private", first_name: "Test" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private", first_name: "Test" },
text: { some: "object" },
caption: "Caption body",
from: { id: 42, first_name: "Alice", is_bot: false },
},
}),
);
expect(result?.body).toBe("Caption body");
expect(result?.kind).toBe("reply");
});
@@ -511,15 +525,15 @@ describe("describeReplyTarget", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
chat: { id: 1, type: "private", first_name: "Test" },
reply_to_message: {
message_id: 1,
date: 900,
chat: { id: 1, type: "private" },
chat: { id: 1, type: "private", first_name: "Test" },
rich_message: { blocks: [{ type: "paragraph" }] },
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
} as TelegramMessage);
expect(result?.body).toBe("[unsupported Telegram rich_message received]");
expect(result?.quoteSourceText).toBeUndefined();
@@ -529,7 +543,7 @@ describe("describeReplyTarget", () => {
const result = describeReplyTarget({
message_id: 2,
date: 1000,
chat: { id: 1, type: "private" },
chat: { id: 1, type: "private", first_name: "Test" },
reply_to_message: {
message_id: 1,
date: 900,
@@ -606,7 +620,7 @@ describe("describeReplyTarget", () => {
caption: "PK\x00\x03\x04binary",
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
} as TelegramMessage);
expect(result?.id).toBe("1");
expect(result?.sender).toBe("Alice");
expect(result?.body).toBeUndefined();
@@ -627,28 +641,30 @@ describe("describeReplyTarget", () => {
text: "Original message",
from: { id: 42, first_name: "Alice", is_bot: false },
},
} as any);
} as TelegramMessage);
expect(result?.body).toBe("Original message");
expect(result?.kind).toBe("reply");
});
it("falls back to external reply text when external quote text is binary", () => {
const result = describeReplyTarget({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Comment on forwarded message",
external_reply: {
message_id: 4,
date: 1200,
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
quote: {
text: "PK\x00\x03\x04binary quote",
text: "Comment on forwarded message",
external_reply: {
message_id: 4,
date: 1200,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
quote: {
text: "PK\x00\x03\x04binary quote",
},
from: { id: 123, first_name: "Eve", is_bot: false },
},
from: { id: 123, first_name: "Eve", is_bot: false },
},
} as any);
}),
);
expect(result?.body).toBe("Forwarded from elsewhere");
expect(result?.kind).toBe("reply");
});
@@ -679,7 +695,7 @@ describe("describeReplyTarget", () => {
date: 500,
},
},
} as any);
} as TelegramMessage);
expect(result?.body).toBe("This is the forwarded content");
expect(result?.id).toBe("2");
expect(result?.forwardedFrom?.from).toBe("Bob Smith (@bobsmith)");
@@ -707,31 +723,33 @@ describe("describeReplyTarget", () => {
author_signature: "Editor",
},
},
} as any);
} as TelegramMessage);
expect(result?.forwardedFrom?.from).toBe("Tech News (Editor)");
expect(result?.forwardedFrom?.fromType).toBe("channel");
expect(result?.forwardedFrom?.fromMessageId).toBe(456);
});
it("marks top-level quote metadata on external replies as external targets", () => {
const result = describeReplyTarget({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Comment on forwarded message",
quote: {
text: "quoted slice",
position: 4,
entities: [{ type: "italic", offset: 0, length: 6 }],
},
external_reply: {
message_id: 4,
date: 1200,
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
from: { id: 123, first_name: "Eve", is_bot: false },
},
} as any);
text: "Comment on forwarded message",
quote: {
text: "quoted slice",
position: 4,
entities: [{ type: "italic", offset: 0, length: 6 }],
},
external_reply: {
message_id: 4,
date: 1200,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
from: { id: 123, first_name: "Eve", is_bot: false },
},
}),
);
expect(result?.id).toBe("4");
expect(result?.kind).toBe("quote");
@@ -742,29 +760,31 @@ describe("describeReplyTarget", () => {
});
it("extracts forwarded context from external_reply", () => {
const result = describeReplyTarget({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Comment on forwarded message",
external_reply: {
message_id: 4,
date: 1200,
const result = describeReplyTarget(
asMalformedTelegramMessage({
message_id: 5,
date: 1300,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
forward_origin: {
type: "user",
sender_user: {
id: 123,
first_name: "Eve",
last_name: "Stone",
username: "eve",
is_bot: false,
text: "Comment on forwarded message",
external_reply: {
message_id: 4,
date: 1200,
chat: { id: 1, type: "private" },
text: "Forwarded from elsewhere",
forward_origin: {
type: "user",
sender_user: {
id: 123,
first_name: "Eve",
last_name: "Stone",
username: "eve",
is_bot: false,
},
date: 700,
},
date: 700,
},
},
} as any);
}),
);
expect(result?.id).toBe("4");
expect(result?.forwardedFrom?.from).toBe("Eve Stone (@eve)");
expect(result?.forwardedFrom?.fromType).toBe("user");
@@ -822,19 +842,21 @@ describe("getTelegramTextParts — binary caption filtering (#66647)", () => {
chat: { id: 1, type: "private" },
date: 1,
message_id: 1,
} as any);
} as TelegramMessage);
expect(result.text).toBe("");
expect(result.entities).toStrictEqual([]);
});
it("preserves normal caption text", () => {
const result = getTelegramTextParts({
caption: "Here is my document",
caption_entities: [],
chat: { id: 1, type: "private" },
date: 1,
message_id: 1,
} as any);
const result = getTelegramTextParts(
asMalformedTelegramMessage({
caption: "Here is my document",
caption_entities: [],
chat: { id: 1, type: "private" },
date: 1,
message_id: 1,
}),
);
expect(result.text).toBe("Here is my document");
});
@@ -845,7 +867,7 @@ describe("getTelegramTextParts — binary caption filtering (#66647)", () => {
chat: { id: 1, type: "private" },
date: 1,
message_id: 1,
} as any);
} as TelegramMessage);
expect(result.text).toBe("");
expect(result.entities).toStrictEqual([]);
});
@@ -860,7 +882,7 @@ describe("hasBotMention", () => {
chat: { id: 1, type: "private" },
date: 1,
message_id: 1,
} as any),
} as TelegramMessage),
).toEqual({
text: "@gaian hello",
entities: [{ type: "mention", offset: 0, length: 6 }],
@@ -873,7 +895,7 @@ describe("hasBotMention", () => {
{
text: "@gaian what is the group id?",
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(true);
@@ -885,7 +907,7 @@ describe("hasBotMention", () => {
{
text: "@GaianChat_Bot what is the group id?",
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(false);
@@ -898,7 +920,7 @@ describe("hasBotMention", () => {
text: "@GaianChat_Bot hi @gaian",
entities: [{ type: "mention", offset: 18, length: 6 }],
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(true);
@@ -913,7 +935,7 @@ describe("hasBotMention", () => {
text,
entities: [{ type: "bot_command", offset: 0, length: "/deploy@gaian".length }],
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(true);
@@ -928,7 +950,7 @@ describe("hasBotMention", () => {
text,
entities: [{ type: "bot_command", offset: 0, length: "/deploy@other_bot".length }],
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(false);
@@ -940,7 +962,7 @@ describe("hasBotMention", () => {
{
text: "@gaian, what's up?",
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(true);
@@ -952,7 +974,7 @@ describe("hasBotMention", () => {
{
text: "@gaian how are you",
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(true);
@@ -964,7 +986,7 @@ describe("hasBotMention", () => {
{
text: "@gaianchat_bot hello",
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(false);
@@ -976,7 +998,7 @@ describe("hasBotMention", () => {
{
text: "@gaianbot do something",
chat: { id: 1, type: "supergroup" },
} as any,
} as TelegramMessage,
"gaian",
),
).toBe(false);
+3 -2
View File
@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Telegram tests cover targets plugin behavior.
import { describe, expect, it } from "vitest";
import {
@@ -200,7 +201,7 @@ describe("telegram group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(
resolveTelegramGroupRequireMention({ cfg: telegramCfg, groupId: "-1001:topic:77" }),
).toBe(false);
@@ -242,7 +243,7 @@ describe("telegram group policy", () => {
},
},
},
} as any;
} as OpenClawConfig;
expect(
resolveTelegramGroupRequireMention({
+21 -12
View File
@@ -33,6 +33,15 @@ const authSuccessHandlers: Array<() => void> = [];
const authFailureHandlers: Array<(text: string, retryCount: number) => void> = [];
const disconnectHandlers: Array<(manual: boolean, reason?: Error) => void> = [];
type TwitchClientManagerState = {
clients: Map<string, unknown>;
messageHandlers: Map<string, (message: TwitchChatMessage) => void>;
};
function managerState(manager: TwitchClientManager): TwitchClientManagerState {
return manager as unknown as TwitchClientManagerState;
}
// Mock functions that track handlers and return unbind objects
const mockOnMessage = vi.fn((handler: any) => {
messageHandlers.push(handler);
@@ -339,7 +348,7 @@ describe("TwitchClientManager", () => {
// The broken auth provider must not be cached as a usable client;
// otherwise later sends fail with an opaque error instead of failing fast.
const key = manager.getAccountKey(refreshingAccount);
expect((manager as any).clients.has(key)).toBe(false);
expect(managerState(manager).clients.has(key)).toBe(false);
});
it("retries client creation after an earlier addUserForToken failure (83853)", async () => {
@@ -423,7 +432,7 @@ describe("TwitchClientManager", () => {
// Check the stored handler is handler2
const key = manager.getAccountKey(testAccount);
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
expect(managerState(manager).messageHandlers.get(key)).toBe(handler2);
});
it("cleanup of an earlier handler does not remove a newer registered handler (#83888)", () => {
@@ -437,7 +446,7 @@ describe("TwitchClientManager", () => {
// Running the first handler's cleanup must not drop handler2.
cleanup1();
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
expect(managerState(manager).messageHandlers.get(key)).toBe(handler2);
});
it("cleanup of an earlier registration does not remove a newer registration using the same handler", () => {
@@ -448,7 +457,7 @@ describe("TwitchClientManager", () => {
manager.onMessage(testAccount, handler);
cleanup1();
expect((manager as any).messageHandlers.get(key)).toBe(handler);
expect(managerState(manager).messageHandlers.get(key)).toBe(handler);
});
it("cleanup of the current handler removes it", () => {
@@ -458,7 +467,7 @@ describe("TwitchClientManager", () => {
const cleanup = manager.onMessage(testAccount, handler);
cleanup();
expect((manager as any).messageHandlers.has(key)).toBe(false);
expect(managerState(manager).messageHandlers.has(key)).toBe(false);
});
});
@@ -479,8 +488,8 @@ describe("TwitchClientManager", () => {
await manager.disconnect(testAccount);
const key = manager.getAccountKey(testAccount);
expect((manager as any).clients.has(key)).toBe(false);
expect((manager as any).messageHandlers.has(key)).toBe(false);
expect(managerState(manager).clients.has(key)).toBe(false);
expect(managerState(manager).messageHandlers.has(key)).toBe(false);
});
it("clears pending client message handlers when disconnect cancels connection", async () => {
@@ -493,7 +502,7 @@ describe("TwitchClientManager", () => {
await manager.disconnect(testAccount);
const key = manager.getAccountKey(testAccount);
expect((manager as any).messageHandlers.has(key)).toBe(false);
expect(managerState(manager).messageHandlers.has(key)).toBe(false);
authSuccessHandlers[0]?.();
await expect(connection).rejects.toThrow("Twitch connection cancelled");
@@ -528,7 +537,7 @@ describe("TwitchClientManager", () => {
expect(mockQuit).toHaveBeenCalledTimes(1);
const key2 = manager.getAccountKey(testAccount2);
expect((manager as any).clients.has(key2)).toBe(true);
expect(managerState(manager).clients.has(key2)).toBe(true);
});
});
@@ -540,8 +549,8 @@ describe("TwitchClientManager", () => {
await manager.disconnectAll();
expect(mockQuit).toHaveBeenCalledTimes(2);
expect((manager as any).clients.size).toBe(0);
expect((manager as any).messageHandlers.size).toBe(0);
expect(managerState(manager).clients.size).toBe(0);
expect(managerState(manager).messageHandlers.size).toBe(0);
});
it("should handle empty client list gracefully", async () => {
@@ -617,7 +626,7 @@ describe("TwitchClientManager", () => {
it("should create client if not already connected", async () => {
// Clear the existing client
(manager as any).clients.clear();
managerState(manager).clients.clear();
// Reset connect call count for this specific test
const connectCallCountBefore = mockConnect.mock.calls.length;
@@ -88,11 +88,13 @@ const callbackMessageWithoutAdmissionFacts = {
},
};
// @ts-expect-error Callback messages must provide admission or deprecated admission fields.
const invalidCallbackMessage: WebInboundCallbackMessage = callbackMessageWithoutAdmissionFacts;
void invalidCallbackMessage;
describe("WhatsApp inbound flat aliases", () => {
it("rejects callback messages without admission facts", () => {
expectTypeOf(
callbackMessageWithoutAdmissionFacts,
).not.toMatchTypeOf<WebInboundCallbackMessage>();
});
it("keeps deprecated admission fields typed on monitor callbacks", () => {
expectTypeOf<MonitorWebInboxMessage>().toMatchTypeOf<{
admission: NonNullable<WebInboundCallbackMessage["admission"]>;
@@ -2,6 +2,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildXaiImageGenerationProvider } from "./image-generation-provider.js";
type GenerateImageParams = Parameters<
ReturnType<typeof buildXaiImageGenerationProvider>["generateImage"]
>[0];
const {
resolveApiKeyForProviderMock,
isProviderApiKeyConfiguredMock,
@@ -170,11 +174,12 @@ describe("xai image generation provider", () => {
providers: {
xai: {
baseUrl: "https://custom.x.ai/v1",
models: [],
},
},
},
},
} as any);
} as GenerateImageParams);
const authParams = (
resolveApiKeyForProviderMock.mock.calls as unknown as Array<[unknown]>
@@ -232,7 +237,7 @@ describe("xai image generation provider", () => {
},
],
cfg: {},
} as any);
} as GenerateImageParams);
const request = requirePostJsonCall();
expect(request.url).toContain("/images/edits");
@@ -259,7 +264,7 @@ describe("xai image generation provider", () => {
model: "grok-imagine-image",
prompt: "ua check",
cfg: {},
} as any);
} as GenerateImageParams);
const request = requirePostJsonCall();
expect(request.headers?.get("user-agent")).toBe("openclaw/2026.3.22");
@@ -292,7 +297,7 @@ describe("xai image generation provider", () => {
{ buffer: Buffer.from("third"), mimeType: "image/webp" },
],
cfg: {},
} as any);
} as GenerateImageParams);
const request = requirePostJsonCall();
expect(request.url).toContain("/images/edits");
@@ -319,7 +324,7 @@ describe("xai image generation provider", () => {
mimeType: "image/png",
})),
cfg: {},
} as any),
} as GenerateImageParams),
).rejects.toThrow("xAI image editing supports up to 3 reference images");
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
+1
View File
@@ -1887,6 +1887,7 @@
"test:docker:upgrade-survivor": "bash scripts/e2e/upgrade-survivor-docker.sh",
"test:env-mutations:report": "node --import tsx scripts/test-env-mutation-report.ts",
"test:skip-inventory:report": "node --import tsx scripts/test-skip-inventory.ts",
"test:type-suppression-inventory:report": "node --import tsx scripts/type-suppression-inventory.ts",
"test:e2e": "pnpm test:e2e:gateway && pnpm test:ui:e2e",
"test:e2e:gateway": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts",
"test:e2e:openshell": "node scripts/run-with-env.mjs OPENCLAW_E2E_OPENSHELL=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts",
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
// Type Suppression Inventory reports unchecked any casts and expected TypeScript errors.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { collectFilesSync, isCodeFile, toPosixPath } from "./check-file-utils.js";
type TypeSuppressionKind = "as-any" | "expect-error" | "type-assertion-any";
export type TypeSuppressionFinding = {
excerpt: string;
file: string;
kind: TypeSuppressionKind;
line: number;
};
export type TypeSuppressionReport = {
findings: TypeSuppressionFinding[];
scannedFileCount: number;
schemaVersion: 1;
summary: {
findingCount: number;
kindCounts: Record<TypeSuppressionKind, number>;
scannedFileCount: number;
touchedFileCount: number;
};
};
const DEFAULT_SCAN_ROOTS = ["src", "test", "extensions", "packages", "ui", "scripts"];
const DEFAULT_SKIPPED_DIR_NAMES = new Set([
".artifacts",
".generated",
"coverage",
"dist",
"fixtures",
"node_modules",
"vendor",
]);
function listGitFiles(repoRoot: string, roots: readonly string[]): string[] | null {
try {
const stdout = execFileSync("git", ["-C", repoRoot, "ls-files", "--", ...roots], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return stdout.split(/\r?\n/u).filter(Boolean);
} catch {
return null;
}
}
function listCandidateFiles(repoRoot: string, roots: readonly string[]): string[] {
const gitFiles = listGitFiles(repoRoot, roots);
const relativeFiles =
gitFiles ??
roots.flatMap((root) => {
const absoluteRoot = path.join(repoRoot, root);
if (!fs.existsSync(absoluteRoot)) {
return [];
}
return collectFilesSync(absoluteRoot, {
includeFile: isCodeFile,
skipDirNames: DEFAULT_SKIPPED_DIR_NAMES,
}).map((filePath) => toPosixPath(path.relative(repoRoot, filePath)));
});
return relativeFiles
.filter((file) => {
const pathSegments = toPosixPath(file).split("/");
return (
/\.[cm]?tsx?$/u.test(file) &&
!file.endsWith(".d.ts") &&
!pathSegments.some((segment) => DEFAULT_SKIPPED_DIR_NAMES.has(segment))
);
})
.toSorted((left, right) => left.localeCompare(right));
}
function sourceKindForFile(file: string): ts.ScriptKind {
return file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
}
function addAnyCastFindings(
sourceFile: ts.SourceFile,
file: string,
findings: TypeSuppressionFinding[],
): void {
const visit = (node: ts.Node): void => {
const kind =
ts.isAsExpression(node) && node.type.kind === ts.SyntaxKind.AnyKeyword
? "as-any"
: ts.isTypeAssertionExpression(node) && node.type.kind === ts.SyntaxKind.AnyKeyword
? "type-assertion-any"
: null;
if (kind) {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
findings.push({
excerpt: node.getText(sourceFile).replace(/\s+/gu, " ").trim(),
file,
kind,
line: line + 1,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
function addExpectErrorFindings(
sourceFile: ts.SourceFile,
file: string,
findings: TypeSuppressionFinding[],
): void {
const source = sourceFile.getFullText();
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
false,
file.endsWith(".tsx") ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard,
source,
);
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
if (
token !== ts.SyntaxKind.SingleLineCommentTrivia &&
token !== ts.SyntaxKind.MultiLineCommentTrivia
) {
continue;
}
const comment = scanner.getTokenText();
const markerPattern = /@ts-expect-error[^\r\n]*/gu;
for (const match of comment.matchAll(markerPattern)) {
const position = scanner.getTokenPos() + (match.index ?? 0);
const line = sourceFile.getLineAndCharacterOfPosition(position).line;
findings.push({
excerpt: match[0].trim(),
file,
kind: "expect-error",
line: line + 1,
});
}
}
}
export function collectTypeSuppressionReport(params: {
files?: readonly string[];
repoRoot: string;
roots?: readonly string[];
}): TypeSuppressionReport {
const files = [
...(params.files ?? listCandidateFiles(params.repoRoot, params.roots ?? DEFAULT_SCAN_ROOTS)),
]
.map(toPosixPath)
.toSorted((left, right) => left.localeCompare(right));
const findings: TypeSuppressionFinding[] = [];
for (const file of files) {
const absolutePath = path.join(params.repoRoot, file);
if (!fs.existsSync(absolutePath)) {
continue;
}
const source = fs.readFileSync(absolutePath, "utf8");
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true,
sourceKindForFile(file),
);
addAnyCastFindings(sourceFile, file, findings);
addExpectErrorFindings(sourceFile, file, findings);
}
findings.sort(
(left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.kind.localeCompare(right.kind),
);
const kindCounts: Record<TypeSuppressionKind, number> = {
"as-any": 0,
"expect-error": 0,
"type-assertion-any": 0,
};
for (const finding of findings) {
kindCounts[finding.kind] = kindCounts[finding.kind] + 1;
}
return {
findings,
scannedFileCount: files.length,
schemaVersion: 1,
summary: {
findingCount: findings.length,
kindCounts,
scannedFileCount: files.length,
touchedFileCount: new Set(findings.map((finding) => finding.file)).size,
},
};
}
function main(): void {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
process.stdout.write(`${JSON.stringify(collectTypeSuppressionReport({ repoRoot }), null, 2)}\n`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
@@ -19,8 +19,12 @@ import {
} from "../infra/diagnostic-events.js";
import { MAX_PLUGIN_APPROVAL_TIMEOUT_MS } from "../infra/plugin-approvals.js";
import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js";
import { PluginApprovalResolutions } from "../plugins/hook-before-tool-call-result.js";
import {
PluginApprovalResolutions,
type PluginApprovalResolution,
} from "../plugins/hook-before-tool-call-result.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { createHookRunner, type HookRunner } from "../plugins/hooks.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { setPluginToolMeta } from "../plugins/tools.js";
@@ -53,10 +57,10 @@ vi.mock("./tools/gateway.js", () => ({
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
const hookRunnerGlobalStateKey = Symbol.for("openclaw.plugins.hook-runner-global-state");
function setGlobalHookRunnerForTest(hookRunner: unknown): void {
function setGlobalHookRunnerForTest(hookRunner: HookRunner | null): void {
const hookRunnerGlobalState = globalThis as Record<
symbol,
{ hookRunner: unknown; registry?: unknown } | undefined
{ hookRunner: HookRunner | null; registry?: unknown } | undefined
>;
if (!hookRunnerGlobalState[hookRunnerGlobalStateKey]) {
hookRunnerGlobalState[hookRunnerGlobalStateKey] = {
@@ -67,27 +71,39 @@ function setGlobalHookRunnerForTest(hookRunner: unknown): void {
hookRunnerGlobalState[hookRunnerGlobalStateKey].hookRunner = hookRunner;
}
function getGlobalHookRunnerForTest(): unknown {
function getGlobalHookRunnerForTest(): HookRunner | null {
const hookRunnerGlobalState = globalThis as Record<
symbol,
{ hookRunner: unknown; registry?: unknown } | undefined
{ hookRunner: HookRunner | null; registry?: unknown } | undefined
>;
return hookRunnerGlobalState[hookRunnerGlobalStateKey]?.hookRunner ?? null;
}
type TestHookRunner = HookRunner & {
hasHooks: ReturnType<typeof vi.fn<HookRunner["hasHooks"]>>;
runBeforeToolCall: ReturnType<typeof vi.fn<HookRunner["runBeforeToolCall"]>>;
};
function createTestHookRunner(): TestHookRunner {
return {
...createHookRunner(createEmptyPluginRegistry()),
hasHooks: vi.fn<HookRunner["hasHooks"]>(),
runBeforeToolCall: vi.fn<HookRunner["runBeforeToolCall"]>(),
};
}
function asAgentTool(tool: { name: string; execute: ReturnType<typeof vi.fn> }): AnyAgentTool {
return tool as unknown as AnyAgentTool;
}
afterEach(() => {
setGlobalHookRunnerForTest(null);
mockGetGlobalHookRunner.mockReset();
mockGetGlobalHookRunner.mockImplementation(
() => getGlobalHookRunnerForTest() as ReturnType<typeof getGlobalHookRunner>,
);
mockGetGlobalHookRunner.mockImplementation(() => getGlobalHookRunnerForTest());
});
describe("before_tool_call loop detection behavior", () => {
let hookRunner: {
hasHooks: ReturnType<typeof vi.fn>;
runBeforeToolCall: ReturnType<typeof vi.fn>;
};
let hookRunner: TestHookRunner;
const enabledLoopDetectionContext = {
agentId: "main",
sessionKey: "main",
@@ -103,11 +119,8 @@ describe("before_tool_call loop detection behavior", () => {
beforeEach(() => {
resetDiagnosticSessionStateForTest();
resetDiagnosticEventsForTest();
hookRunner = {
hasHooks: vi.fn(),
runBeforeToolCall: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as any);
hookRunner = createTestHookRunner();
mockGetGlobalHookRunner.mockReturnValue(hookRunner);
hookRunner.hasHooks.mockReturnValue(false);
});
@@ -374,7 +387,7 @@ describe("before_tool_call loop detection behavior", () => {
content: [{ type: "text", text: "(no new output)\n\nProcess still running." }],
details: { status: "running", aggregated: "steady" },
});
const tool = wrapToolWithBeforeToolCallHook({ name: "process", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "process", execute }), {
...disabledLoopDetectionContext,
});
const params = { action: "poll", sessionId: "sess-off" };
@@ -424,11 +437,11 @@ describe("before_tool_call loop detection behavior", () => {
details: { ok: true },
});
const params = { path: "/tmp/file" };
const firstRunTool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const firstRunTool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
...enabledLoopDetectionContext,
runId: "heartbeat-1",
});
const secondRunTool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const secondRunTool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
...enabledLoopDetectionContext,
runId: "heartbeat-2",
});
@@ -553,7 +566,7 @@ describe("before_tool_call loop detection behavior", () => {
const execute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "ok" }],
});
const tool = wrapToolWithBeforeToolCallHook({ name: "bash", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "bash", execute }), {
agentId: "main",
sessionKey: "session-key",
sessionId: "session-id",
@@ -828,7 +841,7 @@ describe("before_tool_call loop detection behavior", () => {
content: [{ type: "text", text: "tool failed" }],
details,
});
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "exec", execute }), {
agentId: "main",
sessionKey: "session-key",
runId: "run-1",
@@ -877,7 +890,7 @@ describe("before_tool_call loop detection behavior", () => {
const skillBaseDir = path.join(workspaceDir, ".agents", "skills", "demo-skill");
const skillFilePath = path.join(skillBaseDir, "SKILL.md");
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
sessionId: "session-id",
@@ -936,7 +949,7 @@ describe("before_tool_call loop detection behavior", () => {
const skillBaseDir = path.join(os.homedir(), ".openclaw", "skills", "home-skill");
const skillFilePath = path.join(skillBaseDir, "SKILL.md");
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
workspaceDir: "/tmp/openclaw-workspace",
@@ -981,7 +994,7 @@ describe("before_tool_call loop detection behavior", () => {
it("emits skill usage diagnostics for node skill locators", async () => {
const locator = "node://node-1/skills/remote-skill/SKILL.md";
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
skillsSnapshot: {
@@ -1018,7 +1031,7 @@ describe("before_tool_call loop detection behavior", () => {
const readPath = "/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md";
const skillFile = "/agent-workspace/skills/demo/SKILL.md";
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
workspaceDir,
@@ -1058,7 +1071,7 @@ describe("before_tool_call loop detection behavior", () => {
const workspaceDir = path.join("/tmp", "openclaw-skill-unused-param");
const skillBaseDir = path.join(workspaceDir, ".agents", "skills", "demo-skill");
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "readme" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
workspaceDir,
@@ -1101,7 +1114,7 @@ describe("before_tool_call loop detection behavior", () => {
const skillBaseDir = path.join("/tmp", "openclaw-skill-command", "skills", "matrix-profile");
const skillFilePath = path.join(skillBaseDir, "SKILL.md");
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "sent" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "message", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "message", execute }), {
agentId: "main",
sessionKey: "session-key",
sessionId: "session-id",
@@ -1147,7 +1160,7 @@ describe("before_tool_call loop detection behavior", () => {
const execute = vi
.fn()
.mockRejectedValue(new Error("failed with key sk-1234567890abcdef1234567890abcdef"));
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1180,7 +1193,7 @@ describe("before_tool_call loop detection behavior", () => {
abortController.abort();
throw new Error("tool stopped with run");
});
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1212,7 +1225,7 @@ describe("before_tool_call loop detection behavior", () => {
abortController.abort(Object.assign(new Error("timed out"), { name: "TimeoutError" }));
throw new Error("tool stopped with timeout");
});
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1238,7 +1251,7 @@ describe("before_tool_call loop detection behavior", () => {
.mockRejectedValue(
Object.assign(new Error("tool deadline elapsed"), { name: "TimeoutError" }),
);
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1266,7 +1279,7 @@ describe("before_tool_call loop detection behavior", () => {
blockReason: "blocked by policy",
});
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1303,7 +1316,7 @@ describe("before_tool_call loop detection behavior", () => {
blockReason: "blocked by policy",
});
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "nope" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1362,7 +1375,7 @@ describe("before_tool_call loop detection behavior", () => {
},
);
const execute = vi.fn().mockRejectedValue(hostileError);
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1394,7 +1407,7 @@ describe("before_tool_call loop detection behavior", () => {
status: 429,
});
const execute = vi.fn().mockRejectedValue(error);
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1416,7 +1429,7 @@ describe("before_tool_call loop detection behavior", () => {
it("summarizes hostile object params without enumerating keys", async () => {
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "bash", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "bash", execute }), {
agentId: "main",
sessionKey: "session-key",
loopDetection: { enabled: false },
@@ -1445,10 +1458,7 @@ describe("before_tool_call loop detection behavior", () => {
});
describe("before_tool_call requireApproval handling", () => {
let hookRunner: {
hasHooks: ReturnType<typeof vi.fn>;
runBeforeToolCall: ReturnType<typeof vi.fn>;
};
let hookRunner: TestHookRunner;
const mockCallGateway = vi.mocked(callGatewayTool);
function requireRecord(value: unknown, label: string): Record<string, unknown> {
@@ -1508,11 +1518,9 @@ describe("before_tool_call requireApproval handling", () => {
beforeEach(() => {
resetDiagnosticSessionStateForTest();
resetDiagnosticEventsForTest();
hookRunner = {
hasHooks: vi.fn((hookName: string) => hookName === "before_tool_call"),
runBeforeToolCall: vi.fn(),
};
mockGetGlobalHookRunner.mockReturnValue(hookRunner as any);
hookRunner = createTestHookRunner();
hookRunner.hasHooks.mockImplementation((hookName) => hookName === "before_tool_call");
mockGetGlobalHookRunner.mockReturnValue(hookRunner);
// Keep the global singleton aligned as a fallback in case another setup path
// preloads hook-runner-global before this test's module reset/mocks take effect.
setGlobalHookRunnerForTest(hookRunner);
@@ -1522,7 +1530,7 @@ describe("before_tool_call requireApproval handling", () => {
async function runAbortDuringApprovalWait(options?: {
abortReason?: unknown;
onResolution?: ReturnType<typeof vi.fn>;
onResolution?: (decision: PluginApprovalResolution) => void | Promise<void>;
}) {
hookRunner.runBeforeToolCall.mockResolvedValue({
requireApproval: {
@@ -2663,7 +2671,7 @@ describe("before_tool_call tool content private-data capture", () => {
it("attaches tool input/output to private data when opted in", async () => {
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "file body" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
runId: "run-1",
@@ -2688,7 +2696,7 @@ describe("before_tool_call tool content private-data capture", () => {
it("omits tool content from private data when capture is not configured", async () => {
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
runId: "run-1",
@@ -2708,7 +2716,7 @@ describe("before_tool_call tool content private-data capture", () => {
it("captures only opted-in fields and clones away from live params", async () => {
const liveParams = { path: "/etc/secret" };
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "out" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
runId: "run-1",
@@ -2730,7 +2738,7 @@ describe("before_tool_call tool content private-data capture", () => {
it("attaches tool input but not output on execution errors", async () => {
const execute = vi.fn().mockRejectedValue(new Error("boom"));
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }), {
agentId: "main",
sessionKey: "session-key",
runId: "run-1",
@@ -31,6 +31,7 @@ import {
wrapToolWithBeforeToolCallHook,
} from "./agent-tools.before-tool-call.js";
import { normalizeToolParameters } from "./agent-tools.schema.js";
import type { AnyAgentTool } from "./agent-tools.types.js";
import { markCodeModeControlTool } from "./code-mode-control-tools.js";
import { CODE_MODE_EXEC_TOOL_NAME, createCodeModeTools } from "./code-mode.js";
import { splitSdkTools } from "./embedded-agent-runner.js";
@@ -39,6 +40,15 @@ import { setToolTerminalPresentation } from "./tool-terminal-presentation.js";
type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
function asAgentTool(tool: {
description?: string;
execute: ReturnType<typeof vi.fn>;
name: string;
parameters?: object;
}): AnyAgentTool {
return tool as unknown as AnyAgentTool;
}
type BeforeToolCallHookInstall = {
pluginId: string;
priority?: number;
@@ -103,7 +113,7 @@ describe("before_tool_call hook integration", () => {
it("executes tool normally when no hook is registered", async () => {
beforeToolCallHook = installBeforeToolCallHook({ enabled: false });
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "Read", execute }), {
agentId: "main",
sessionKey: "main",
});
@@ -123,10 +133,10 @@ describe("before_tool_call hook integration", () => {
it("records structured replay trust only for concrete core-owned tools", async () => {
beforeToolCallHook = installBeforeToolCallHook({ enabled: false });
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const coreTool = wrapToolWithBeforeToolCallHook({ name: "search", execute } as any, {
const coreTool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "search", execute }), {
runId: "run-core",
});
const pluginSource = { name: "search", execute } as any;
const pluginSource = asAgentTool({ name: "search", execute });
setPluginToolMeta(pluginSource, { pluginId: "example", optional: false });
const pluginTool = wrapToolWithBeforeToolCallHook(pluginSource, {
runId: "run-plugin",
@@ -173,7 +183,7 @@ describe("before_tool_call hook integration", () => {
runBeforeToolCallImpl: async () => ({ params: { mode: "safe" } }),
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any);
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "exec", execute }));
const extensionContext = {} as Parameters<typeof tool.execute>[3];
await tool.execute("call-2", { cmd: "ls" }, undefined, extensionContext);
@@ -194,7 +204,7 @@ describe("before_tool_call hook integration", () => {
}),
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any);
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "exec", execute }));
const extensionContext = {} as Parameters<typeof tool.execute>[3];
await expect(
@@ -219,7 +229,7 @@ describe("before_tool_call hook integration", () => {
]);
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any);
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "exec", execute }));
const extensionContext = {} as Parameters<typeof tool.execute>[3];
await expect(
@@ -245,7 +255,7 @@ describe("before_tool_call hook integration", () => {
},
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any);
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "read", execute }));
const extensionContext = {} as Parameters<typeof tool.execute>[3];
await expect(
@@ -259,7 +269,7 @@ describe("before_tool_call hook integration", () => {
runBeforeToolCallImpl: async () => undefined,
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = wrapToolWithBeforeToolCallHook({ name: "ReAd", execute } as any, {
const tool = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "ReAd", execute }), {
agentId: "main",
sessionKey: "main",
sessionId: "ephemeral-main",
@@ -296,10 +306,10 @@ describe("before_tool_call hook integration", () => {
.mockResolvedValueOnce({ params: { marker: "B" } }),
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const toolA = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
const toolA = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "Read", execute }), {
runId: "run-a",
});
const toolB = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
const toolB = wrapToolWithBeforeToolCallHook(asAgentTool({ name: "Read", execute }), {
runId: "run-b",
});
const extensionContextA = {} as Parameters<typeof toolA.execute>[3];
@@ -334,7 +344,12 @@ describe("before_tool_call hook deduplication (#15502)", () => {
it("fires hook exactly once when tool goes through wrap + toToolDefinitions", async () => {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const baseTool = { name: "web_fetch", execute, description: "fetch", parameters: {} } as any;
const baseTool = asAgentTool({
name: "web_fetch",
execute,
description: "fetch",
parameters: {},
});
const wrapped = wrapToolWithBeforeToolCallHook(baseTool, {
agentId: "main",
@@ -553,7 +568,14 @@ describe("before_tool_call hook deduplication (#15502)", () => {
});
const plainExecute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const [plainExecDef] = toToolDefinitions(
[{ name: "exec", execute: plainExecute, description: "Plain exec", parameters: {} } as any],
[
asAgentTool({
name: "exec",
execute: plainExecute,
description: "Plain exec",
parameters: {},
}),
],
{
agentId: "main",
sessionKey: "agent:main:main",
@@ -705,12 +727,14 @@ describe("before_tool_call hook deduplication (#15502)", () => {
runBeforeToolCallImpl: async () => ({ params: { command: "return 2;" } }),
});
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = markCodeModeControlTool({
name: CODE_MODE_EXEC_TOOL_NAME,
execute,
description: "exec",
parameters: {},
} as any);
const tool = markCodeModeControlTool(
asAgentTool({
name: CODE_MODE_EXEC_TOOL_NAME,
execute,
description: "exec",
parameters: {},
}),
);
const [def] = toToolDefinitions([tool], {
agentId: "main",
sessionKey: "agent:main:main",
@@ -813,12 +837,14 @@ describe("before_tool_call hook deduplication (#15502)", () => {
initializeGlobalHookRunner(registry);
try {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const tool = markCodeModeControlTool({
name: CODE_MODE_EXEC_TOOL_NAME,
execute,
description: "exec",
parameters: {},
} as any);
const tool = markCodeModeControlTool(
asAgentTool({
name: CODE_MODE_EXEC_TOOL_NAME,
execute,
description: "exec",
parameters: {},
}),
);
const [def] = toToolDefinitions([tool], {
agentId: "main",
sessionKey: "agent:main:main",
@@ -973,7 +999,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
it("fires hook exactly once when tool goes through wrap + abort + toToolDefinitions", async () => {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const baseTool = { name: "Bash", execute, description: "bash", parameters: {} } as any;
const baseTool = asAgentTool({ name: "Bash", execute, description: "bash", parameters: {} });
const abortController = new AbortController();
const wrapped = wrapToolWithBeforeToolCallHook(baseTool, {
@@ -998,7 +1024,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
it("emits a tool-authored terminal presentation with the recorded outcome", async () => {
const onToolOutcome = vi.fn();
const sourceTool = setToolTerminalPresentation(
{
asAgentTool({
name: "web_fetch",
description: "fetch",
parameters: {},
@@ -1006,7 +1032,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
content: [],
details: { status: 200 },
}),
} as any,
}),
(_params, result) => ({
text: `Fetched with status ${(result.details as { status: number }).status}`,
}),
@@ -1063,23 +1089,23 @@ describe("before_tool_call hook deduplication (#15502)", () => {
};
const presentationTool = wrapToolWithBeforeToolCallHook(
setToolTerminalPresentation(
{
asAgentTool({
name: "web_fetch",
description: "fetch",
parameters: {},
execute: vi.fn(() => presentationExecution),
} as any,
}),
() => ({ text: "Fetched with status 200" }),
),
hookContext,
);
const plainTool = wrapToolWithBeforeToolCallHook(
{
asAgentTool({
name: "read_file",
description: "read",
parameters: {},
execute: vi.fn(() => plainExecution),
} as any,
}),
hookContext,
);
@@ -1119,7 +1145,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
it("passes hook context for unwrapped tool definitions", async () => {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const baseTool = { name: "exec", execute, description: "exec", parameters: {} } as any;
const baseTool = asAgentTool({ name: "exec", execute, description: "exec", parameters: {} });
const def = expectDefined(
toToolDefinitions([baseTool], {
agentId: "code-agent",
@@ -1162,7 +1188,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
it("preserves the hook marker when abort wrapping a hooked tool", () => {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
const baseTool = { name: "Bash", execute, description: "bash", parameters: {} } as any;
const baseTool = asAgentTool({ name: "Bash", execute, description: "bash", parameters: {} });
const wrapped = wrapToolWithBeforeToolCallHook(baseTool, {
agentId: "main",
sessionKey: "main",
+3 -3
View File
@@ -34,7 +34,7 @@ describe("compaction token accounting sanitization", () => {
content: [{ type: "text", text: "ok" }],
details: { raw: "x".repeat(50_000) },
timestamp: 1,
} as any,
} as AgentMessage,
{
role: "user",
content: "next",
@@ -65,13 +65,13 @@ describe("compaction token accounting sanitization", () => {
content: [{ type: "text", text: "ok" }],
details: { raw: "x".repeat(50_000) },
timestamp: 1,
} as any,
} as AgentMessage,
{
role: "custom",
customType: "openclaw.runtime-context",
content: "internal",
timestamp: 2,
} as any,
} as AgentMessage,
{
role: "user",
content: "next",
@@ -795,7 +795,9 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
expect(
downgradeOpenAIReasoningBlocks(input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0]),
).toEqual(input);
});
it("drops replayable reasoning when requested even with following content", () => {
@@ -813,9 +815,12 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any, { dropReplayableReasoning: true })).toEqual(
[{ role: "assistant", content: [{ type: "text", text: "answer" }] }],
);
expect(
downgradeOpenAIReasoningBlocks(
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
{ dropReplayableReasoning: true },
),
).toEqual([{ role: "assistant", content: [{ type: "text", text: "answer" }] }]);
});
it("drops the paired message id when replayable reasoning is dropped", () => {
@@ -837,9 +842,12 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any, { dropReplayableReasoning: true })).toEqual(
[{ role: "assistant", content: [{ type: "text", text: "answer" }] }],
);
expect(
downgradeOpenAIReasoningBlocks(
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
{ dropReplayableReasoning: true },
),
).toEqual([{ role: "assistant", content: [{ type: "text", text: "answer" }] }]);
});
it("keeps the paired message id when reasoning is preserved", () => {
@@ -861,7 +869,9 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
expect(
downgradeOpenAIReasoningBlocks(input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0]),
).toEqual(input);
});
it("drops paired message ids across every text block when reasoning is dropped", () => {
@@ -887,25 +897,28 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any, { dropReplayableReasoning: true })).toEqual(
[
{
role: "assistant",
content: [
{
type: "text",
text: "commentary",
textSignature: JSON.stringify({ v: 1, phase: "commentary" }),
},
{
type: "text",
text: "final",
textSignature: JSON.stringify({ v: 1, phase: "final_answer" }),
},
],
},
],
);
expect(
downgradeOpenAIReasoningBlocks(
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
{ dropReplayableReasoning: true },
),
).toEqual([
{
role: "assistant",
content: [
{
type: "text",
text: "commentary",
textSignature: JSON.stringify({ v: 1, phase: "commentary" }),
},
{
type: "text",
text: "final",
textSignature: JSON.stringify({ v: 1, phase: "final_answer" }),
},
],
},
]);
});
it("drops orphaned reasoning blocks without following content", () => {
@@ -922,9 +935,9 @@ describe("downgradeOpenAIReasoningBlocks", () => {
{ role: "user", content: "next" },
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
{ role: "user", content: "next" },
]);
expect(
downgradeOpenAIReasoningBlocks(input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0]),
).toEqual([{ role: "user", content: "next" }]);
});
it("drops object-form orphaned signatures", () => {
@@ -940,7 +953,11 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toStrictEqual([]);
expect(
downgradeOpenAIReasoningBlocks(
input as unknown as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
),
).toStrictEqual([]);
});
it("keeps non-reasoning thinking signatures", () => {
@@ -957,7 +974,9 @@ describe("downgradeOpenAIReasoningBlocks", () => {
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
expect(
downgradeOpenAIReasoningBlocks(input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0]),
).toEqual(input);
});
it("is idempotent for orphaned reasoning cleanup", () => {
@@ -974,8 +993,12 @@ describe("downgradeOpenAIReasoningBlocks", () => {
{ role: "user", content: "next" },
];
const once = downgradeOpenAIReasoningBlocks(input as any);
const twice = downgradeOpenAIReasoningBlocks(once as any);
const once = downgradeOpenAIReasoningBlocks(
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
);
const twice = downgradeOpenAIReasoningBlocks(
once as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
);
expect(twice).toEqual(once);
});
});
@@ -1019,7 +1042,11 @@ describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
makeToolResult(callIdWithReasoning, "ok"),
];
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
expect(
downgradeOpenAIFunctionCallReasoningPairs(
input as Parameters<typeof downgradeOpenAIFunctionCallReasoningPairs>[0],
),
).toEqual([
makePlainAssistantTurn(callIdWithoutReasoning),
makeToolResult(callIdWithoutReasoning, "ok"),
]);
@@ -1031,7 +1058,11 @@ describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
makeToolResult(callIdWithReasoning, "ok"),
];
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual(input);
expect(
downgradeOpenAIFunctionCallReasoningPairs(
input as Parameters<typeof downgradeOpenAIFunctionCallReasoningPairs>[0],
),
).toEqual(input);
});
it("only rewrites tool results paired to the downgraded assistant turn", () => {
@@ -1042,7 +1073,11 @@ describe("downgradeOpenAIFunctionCallReasoningPairs", () => {
makeToolResult(callIdWithReasoning, "turn2"),
];
expect(downgradeOpenAIFunctionCallReasoningPairs(input as any)).toEqual([
expect(
downgradeOpenAIFunctionCallReasoningPairs(
input as Parameters<typeof downgradeOpenAIFunctionCallReasoningPairs>[0],
),
).toEqual([
makePlainAssistantTurn(callIdWithoutReasoning),
makeToolResult(callIdWithoutReasoning, "turn1"),
makeReasoningAssistantTurn(callIdWithReasoning),
@@ -13,6 +13,9 @@ import { loadOpenClawPlugins } from "../plugins/loader.js";
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
type PersistedToolResultMessage = ToolResultMessage & { details: Record<string, unknown> };
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
@@ -53,7 +56,7 @@ function appendToolCallAndResult(sm: ReturnType<typeof SessionManager.inMemory>)
isError: false,
content: [{ type: "text", text: "ok" }],
details: { big: "x".repeat(10_000) },
} as any);
} as ToolResultMessage);
}
function appendToolResultWithTail(
@@ -71,7 +74,7 @@ function appendToolResultWithTail(
isError: false,
content: [{ type: "text", text: "visible output stays small" }],
details: { status: "completed", tail },
} as any);
} as ToolResultMessage);
}
function getPersistedToolResult(sm: ReturnType<typeof SessionManager.inMemory>) {
@@ -80,10 +83,18 @@ function getPersistedToolResult(sm: ReturnType<typeof SessionManager.inMemory>)
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
return messages.find((m) => (m as any).role === "toolResult") as any;
return messages.find((message): message is ToolResultMessage => message.role === "toolResult");
}
function requirePersistedToolResult(sm: ReturnType<typeof SessionManager.inMemory>) {
function hasRecordDetails(message: ToolResultMessage): message is PersistedToolResultMessage {
return (
typeof message.details === "object" &&
message.details !== null &&
!Array.isArray(message.details)
);
}
function requirePersistedToolResultMessage(sm: ReturnType<typeof SessionManager.inMemory>) {
const toolResult = getPersistedToolResult(sm);
if (!toolResult) {
throw new Error("expected persisted toolResult message");
@@ -91,6 +102,22 @@ function requirePersistedToolResult(sm: ReturnType<typeof SessionManager.inMemor
return toolResult;
}
function requirePersistedToolResult(sm: ReturnType<typeof SessionManager.inMemory>) {
const toolResult = requirePersistedToolResultMessage(sm);
if (!hasRecordDetails(toolResult)) {
throw new Error("expected persisted toolResult message with object details");
}
return toolResult;
}
function requireToolResultText(message: ToolResultMessage): string {
const text = message.content.find((block) => block.type === "text")?.text;
if (text === undefined) {
throw new Error("expected persisted toolResult text content");
}
return text;
}
function initializeTempPlugin(params: { tmpPrefix: string; id: string; body: string }) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix));
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
@@ -114,8 +141,7 @@ function initializeTempPlugin(params: { tmpPrefix: string; id: string; body: str
function expectPersistedToolResultTextCapped(sm: ReturnType<typeof SessionManager.inMemory>) {
const toolResult = requirePersistedToolResult(sm);
const text = toolResult.content.find((block: { type: string }) => block.type === "text")?.text;
expect(typeof text).toBe("string");
const text = requireToolResultText(toolResult);
expect(text.length).toBeLessThanOrEqual(120);
expect(text).toContain("truncated");
}
@@ -123,7 +149,7 @@ function expectPersistedToolResultTextCapped(sm: ReturnType<typeof SessionManage
function expectPersistedToolResultDetailsCapped(sm: ReturnType<typeof SessionManager.inMemory>) {
// Large details are summarized before persistence to keep transcript files bounded.
const toolResult = requirePersistedToolResult(sm);
const details = toolResult.details as Record<string, unknown>;
const details = toolResult.details;
expect(details.persistedDetailsTruncated).toBe(true);
expect(details.aggregated).toBeUndefined();
expect(Buffer.byteLength(JSON.stringify(details), "utf-8")).toBeLessThan(8_192);
@@ -184,7 +210,7 @@ describe("tool_result_persist hook", () => {
error: null,
payload: "x".repeat(10_000),
},
} as any);
} as ToolResultMessage);
const details = requirePersistedToolResult(sm).details;
expect(details.persistedDetailsTruncated).toBe(true);
@@ -229,11 +255,11 @@ describe("tool_result_persist hook", () => {
items: [`curl --token ${tokenValue} https://example.test`],
},
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult.details);
expect(toolResult.content[0]?.text).toBe("visible output stays small");
expect(requireToolResultText(toolResult)).toBe("visible output stays small");
expect(serialized).toContain("GITHUB_TOKEN=");
expect(serialized).toContain("Bearer");
expect(serialized).toContain("…");
@@ -268,7 +294,7 @@ describe("tool_result_persist hook", () => {
details: {
diagnostic: customSecret,
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult);
@@ -303,7 +329,7 @@ describe("tool_result_persist hook", () => {
details: {
token: { value: "shortsecret" },
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult.details);
@@ -335,7 +361,7 @@ describe("tool_result_persist hook", () => {
[`https://example.test/callback?token=${tokenValue}`]: "ok",
deepDetails,
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult.details);
@@ -375,10 +401,10 @@ describe("tool_result_persist hook", () => {
},
],
},
} as any);
} as ToolResultMessage);
const toolResult = getPersistedToolResult(sm);
expect(toolResult.content[0]?.text).toBe("visible output stays small");
const toolResult = requirePersistedToolResult(sm);
expect(requireToolResultText(toolResult)).toBe("visible output stays small");
expectPersistedToolResultDetailsCapped(sm);
});
@@ -443,11 +469,11 @@ describe("tool_result_persist hook", () => {
},
],
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult.details);
expect(toolResult.content[0]?.text).toBe("visible output stays small");
expect(requireToolResultText(toolResult)).toBe("visible output stays small");
expect(toolResult.details.persistedDetailsTruncated).toBe(true);
expect(serialized).toContain("token=***");
expect(serialized).toContain("partial secret span omitted");
@@ -490,14 +516,14 @@ describe("tool_result_persist hook", () => {
command: `node script-${i}.js ${"x".repeat(6_000)}`,
})),
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const details = toolResult.details;
const serialized = JSON.stringify(details);
expect(details.persistedDetailsTruncated).toBe(true);
expect(details.finalDetailsTruncated).toBe(true);
expect(details.status?.token).toBe("***");
expect(details.status).toMatchObject({ token: "***" });
expect(details.spilledChars).toBe(2_000_000);
expect(details.spillTruncated).toBe(true);
expect(serialized).not.toContain(tokenValue);
@@ -531,7 +557,7 @@ describe("tool_result_persist hook", () => {
aggregated: "x".repeat(120_000),
tail,
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult.details);
@@ -561,7 +587,7 @@ describe("tool_result_persist hook", () => {
status: "completed",
tail: `${"x".repeat(1_000)}{"token":"${longSecret}${"z".repeat(1_000)}`,
},
} as any);
} as ToolResultMessage);
const toolResult = requirePersistedToolResult(sm);
const serialized = JSON.stringify(toolResult.details);
@@ -606,13 +632,13 @@ describe("tool_result_persist hook", () => {
isError: false,
content: [{ type: "text", text: "visible output stays small" }],
details: oversizedDetails,
} as any);
} as ToolResultMessage);
} finally {
stringifySpy.mockRestore();
}
const toolResult = getPersistedToolResult(sm);
expect(toolResult.content[0]?.text).toBe("visible output stays small");
const toolResult = requirePersistedToolResult(sm);
expect(requireToolResultText(toolResult)).toBe("visible output stays small");
expectPersistedToolResultDetailsCapped(sm);
expect(stringifySpy).not.toHaveBeenCalledWith(oversizedDetails);
});
@@ -656,14 +682,14 @@ describe("tool_result_persist hook", () => {
isError: false,
content: [{ type: "text", text: "visible output stays small" }],
details: wideDetails,
} as any);
} as ToolResultMessage);
} finally {
entriesSpy.mockRestore();
keysSpy.mockRestore();
}
const toolResult = getPersistedToolResult(sm);
const details = toolResult.details as Record<string, unknown>;
const toolResult = requirePersistedToolResult(sm);
const details = toolResult.details;
expect(details.persistedDetailsTruncated).toBe(true);
expect(details.originalDetailKeys).toContain("status");
expect(details.originalDetailKeys).toContain("sessionId");
@@ -706,10 +732,10 @@ describe("tool_result_persist hook", () => {
tail: "z".repeat(10_000),
})),
},
} as any);
} as ToolResultMessage);
const toolResult = getPersistedToolResult(sm);
const details = toolResult.details as Record<string, unknown>;
const toolResult = requirePersistedToolResult(sm);
const details = toolResult.details;
expect(details.persistedDetailsTruncated).toBe(true);
expect(details.finalDetailsTruncated).toBe(true);
expect(details.aggregated).toBeUndefined();
@@ -765,7 +791,7 @@ describe("tool_result_persist hook", () => {
});
appendToolCallAndResult(sm);
const toolResult = requirePersistedToolResult(sm);
const toolResult = requirePersistedToolResultMessage(sm);
// Hook registration should preserve a valid toolResult message shape.
expect(toolResult.role).toBe("toolResult");
+3 -3
View File
@@ -376,7 +376,7 @@ describe("edit tool", () => {
const tc0 = expectDefined(result.content[0], "result.content[0] test invariant");
expect("text" in tc0 ? tc0.text : "").toContain("No changes made");
expect((result as any).terminate).toBe(true);
expect((result as { terminate?: boolean }).terminate).toBe(true);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("unchanged content\n");
});
@@ -504,7 +504,7 @@ describe("edit tool", () => {
undefined,
);
expect((result as any).terminate).toBe(true);
expect((result as { terminate?: boolean }).terminate).toBe(true);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("foo\n");
});
@@ -602,7 +602,7 @@ describe("edit tool", () => {
const tcText = expectDefined(result.content[0], "result.content[0] test invariant");
expect("text" in tcText ? tcText.text : "").toContain("Successfully replaced");
expect((result as any).terminate).toBeFalsy();
expect((result as { terminate?: boolean }).terminate).toBeFalsy();
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("alpha beta GAMMA\n");
});
+1 -1
View File
@@ -138,7 +138,7 @@ describe("write tool", () => {
const tc0 = expectDefined(result.content[0], "result.content[0] test invariant");
expect("text" in tc0 ? tc0.text : "").toContain("No changes made");
expect((result as any).terminate).toBe(true);
expect((result as { terminate?: boolean }).terminate).toBe(true);
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("hello\n");
});
+34 -29
View File
@@ -22,6 +22,11 @@ vi.mock("../logging/subsystem.js", () => ({
}));
type DummyTool = { name: string };
type PolicyTool = Parameters<typeof applyToolPolicyPipeline>[0]["tools"][number];
function asPolicyTools(tools: DummyTool[]): PolicyTool[] {
return tools as PolicyTool[];
}
function runAllowlistWarningStep(params: {
allow: string[];
@@ -33,7 +38,7 @@ function runAllowlistWarningStep(params: {
const warnings: string[] = [];
const tools = [{ name: "exec" }] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
steps: [
@@ -61,7 +66,7 @@ describe("tool-policy-pipeline", () => {
test("preserves plugin-only allowlists instead of silently stripping them", () => {
const tools = [{ name: "exec" }, { name: "plugin_tool" }] as unknown as DummyTool[];
const filtered = applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: (t: any) => (t.name === "plugin_tool" ? { pluginId: "foo" } : undefined),
warn: () => {},
steps: [
@@ -80,7 +85,7 @@ describe("tool-policy-pipeline", () => {
const warnings: string[] = [];
const tools = [{ name: "exec" }] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
steps: [
@@ -142,7 +147,7 @@ describe("tool-policy-pipeline", () => {
const warnings: string[] = [];
const profilePolicy = resolveToolProfilePolicy("coding");
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
steps: buildDefaultToolPolicyPipelineSteps({
@@ -158,7 +163,7 @@ describe("tool-policy-pipeline", () => {
test("does not warn for declared plugin tools that are not materialized yet", () => {
const warnings: string[] = [];
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: { pluginToolNames: ["llm-task"] },
@@ -177,7 +182,7 @@ describe("tool-policy-pipeline", () => {
test("does not warn for declared MCP server namespace globs", () => {
const warnings: string[] = [];
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: { mcpServerNames: ["paperless", "Home Assistant"] },
@@ -196,7 +201,7 @@ describe("tool-policy-pipeline", () => {
test("still warns for undeclared MCP namespace globs", () => {
const warnings: string[] = [];
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: { mcpServerNames: ["paperless"] },
@@ -260,7 +265,7 @@ describe("tool-policy-pipeline", () => {
});
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -289,7 +294,7 @@ describe("tool-policy-pipeline", () => {
});
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -318,7 +323,7 @@ describe("tool-policy-pipeline", () => {
});
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -347,7 +352,7 @@ describe("tool-policy-pipeline", () => {
});
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -376,7 +381,7 @@ describe("tool-policy-pipeline", () => {
});
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -403,7 +408,7 @@ describe("tool-policy-pipeline", () => {
});
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -439,7 +444,7 @@ describe("tool-policy-pipeline", () => {
expect(Array.from(declared?.mcpServerNames ?? [])).toEqual(["vigil-harbor"]);
applyToolPolicyPipeline({
tools: [{ name: "exec" }] as any,
tools: asPolicyTools([{ name: "exec" }]),
toolMeta: () => undefined,
warn: (msg) => warnings.push(msg),
declaredToolAllowlist: declared,
@@ -461,7 +466,7 @@ describe("tool-policy-pipeline", () => {
const warnings: string[] = [];
const tools = [{ name: "exec" }] as unknown as DummyTool[];
const params = {
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg: string) => warnings.push(msg),
steps: [
@@ -487,7 +492,7 @@ describe("tool-policy-pipeline", () => {
for (let i = 0; i < 257; i += 1) {
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg: string) => warnings.push(msg),
steps: [
@@ -501,7 +506,7 @@ describe("tool-policy-pipeline", () => {
}
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg: string) => warnings.push(msg),
steps: [
@@ -522,7 +527,7 @@ describe("tool-policy-pipeline", () => {
for (let i = 0; i < 256; i += 1) {
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg: string) => warnings.push(msg),
steps: [
@@ -538,7 +543,7 @@ describe("tool-policy-pipeline", () => {
warnings.length = 0;
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg: string) => warnings.push(msg),
steps: [
@@ -550,7 +555,7 @@ describe("tool-policy-pipeline", () => {
],
});
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: (msg: string) => warnings.push(msg),
steps: [
@@ -567,7 +572,7 @@ describe("tool-policy-pipeline", () => {
test("applies allowlist filtering when core tools are explicitly listed", () => {
const tools = [{ name: "exec" }, { name: "process" }] as unknown as DummyTool[];
const filtered = applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -584,7 +589,7 @@ describe("tool-policy-pipeline", () => {
test("applies deny filtering after allow filtering", () => {
const tools = [{ name: "exec" }, { name: "process" }] as unknown as DummyTool[];
const filtered = applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -607,7 +612,7 @@ describe("tool-policy-pipeline", () => {
] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -635,7 +640,7 @@ describe("tool-policy-pipeline", () => {
const tools = [{ name: "exec" }, { name: "browser" }] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
auditLogLevel: "debug",
@@ -664,7 +669,7 @@ describe("tool-policy-pipeline", () => {
const tools = [{ name: "exec" }, { name: "browser" }] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -697,7 +702,7 @@ describe("tool-policy-pipeline", () => {
] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -736,7 +741,7 @@ describe("tool-policy-pipeline", () => {
const tools = [{ name: "exec" }] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -755,7 +760,7 @@ describe("tool-policy-pipeline", () => {
const tools = [{ name: "exec\nbad" }] as unknown as DummyTool[];
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
@@ -784,7 +789,7 @@ describe("tool-policy-pipeline", () => {
const labelPrefix = "a".repeat(159);
applyToolPolicyPipeline({
tools: tools as any,
tools: asPolicyTools(tools),
toolMeta: () => undefined,
warn: () => {},
steps: [
+2 -1
View File
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type { Insertable } from "kysely";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
executeSqliteQuerySync,
@@ -609,7 +610,7 @@ describe("channel ingress queue", () => {
claim_owner: overrides.claim_owner ?? null,
claimed_at: overrides.claimed_at ?? null,
completed_at: overrides.completed_at ?? null,
} as any),
} as Insertable<OpenClawStateKyselyDatabase["channel_ingress_events"]>),
);
}
@@ -1,15 +1,24 @@
// Setup group access configure tests cover channel setup writes for group access config.
import { describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../../test/helpers/wizard-prompter.js";
import type { OpenClawConfig } from "../../config/config.js";
import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js";
import type { ChannelAccessPolicy } from "./setup-group-access.js";
function createPrompter(params: { confirm: boolean; policy?: ChannelAccessPolicy; text?: string }) {
const confirm = vi.fn(async () => params.confirm);
const text = vi.fn(async () => params.text ?? "");
const note = vi.fn();
const prompter = createWizardPrompter(
{ confirm, text, note },
{ defaultSelect: params.policy ?? "allowlist" },
);
return {
confirm: vi.fn(async () => params.confirm),
select: vi.fn(async () => params.policy ?? "allowlist"),
text: vi.fn(async () => params.text ?? ""),
note: vi.fn(),
...prompter,
confirm,
select: vi.mocked(prompter.select),
text,
note,
};
}
@@ -24,7 +33,7 @@ async function runConfigureChannelAccess<TResolved>(params: {
}) {
return await configureChannelAccessWithAllowlist({
cfg: params.cfg,
prompter: params.prompter as any,
prompter: params.prompter,
label: params.label ?? "Slack channels",
currentPolicy: "allowlist",
currentEntries: [],
@@ -106,7 +115,7 @@ describe("configureChannelAccessWithAllowlist", () => {
const next = await configureChannelAccessWithAllowlist({
cfg,
prompter: prompter as any,
prompter,
label: "Twitch chat",
currentPolicy: "disabled",
currentEntries: [],
+37 -33
View File
@@ -1,5 +1,6 @@
// Setup group access tests cover group access setup flow decisions and outputs.
import { describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../../test/helpers/wizard-prompter.js";
import {
formatAllowlistEntries,
parseAllowlistEntries,
@@ -9,22 +10,27 @@ import {
} from "./setup-group-access.js";
function createPrompter(params?: {
confirm?: (options: { message: string; initialValue: boolean }) => Promise<boolean>;
select?: (options: {
message: string;
options: Array<{ value: string; label: string }>;
initialValue?: string;
}) => Promise<string>;
text?: (options: {
message: string;
placeholder?: string;
initialValue?: string;
}) => Promise<string>;
confirm?: boolean;
select?: string;
text?: string;
textError?: string;
}) {
const confirm = vi.fn(async () => params?.confirm ?? true);
const text = vi.fn(async () => {
if (params?.textError) {
throw new Error(params.textError);
}
return params?.text ?? "";
});
const prompter = createWizardPrompter(
{ confirm, text },
{ defaultSelect: params?.select ?? "allowlist" },
);
return {
confirm: vi.fn(params?.confirm ?? (async () => true)),
select: vi.fn(params?.select ?? (async () => "allowlist")),
text: vi.fn(params?.text ?? (async () => "")),
...prompter,
confirm,
select: vi.mocked(prompter.select),
text,
};
}
@@ -48,11 +54,11 @@ describe("formatAllowlistEntries", () => {
describe("promptChannelAllowlist", () => {
it("uses existing entries as initial value", async () => {
const prompter = createPrompter({
text: async () => "one,two",
text: "one,two",
});
const result = await promptChannelAllowlist({
prompter: prompter as any,
prompter,
label: "Test",
currentEntries: ["alpha", "beta"],
});
@@ -69,11 +75,11 @@ describe("promptChannelAllowlist", () => {
describe("promptChannelAccessPolicy", () => {
it("returns selected policy", async () => {
const prompter = createPrompter({
select: async () => "open",
select: "open",
});
const result = await promptChannelAccessPolicy({
prompter: prompter as any,
prompter,
label: "Discord",
currentPolicy: "allowlist",
});
@@ -85,15 +91,13 @@ describe("promptChannelAccessPolicy", () => {
describe("promptChannelAccessConfig policy-only entries", () => {
it("skips the allowlist text prompt when entries are policy-only", async () => {
const prompter = createPrompter({
confirm: async () => true,
select: async () => "allowlist",
text: async () => {
throw new Error("text prompt should not run");
},
confirm: true,
select: "allowlist",
textError: "text prompt should not run",
});
const result = await promptChannelAccessConfig({
prompter: prompter as any,
prompter,
label: "Twitch chat",
skipAllowlistEntries: true,
});
@@ -105,11 +109,11 @@ describe("promptChannelAccessConfig policy-only entries", () => {
describe("promptChannelAccessConfig skip flow", () => {
it("returns null when user skips configuration", async () => {
const prompter = createPrompter({
confirm: async () => false,
confirm: false,
});
const result = await promptChannelAccessConfig({
prompter: prompter as any,
prompter,
label: "Slack",
});
@@ -118,13 +122,13 @@ describe("promptChannelAccessConfig skip flow", () => {
it("returns allowlist entries when policy is allowlist", async () => {
const prompter = createPrompter({
confirm: async () => true,
select: async () => "allowlist",
text: async () => "c1, c2",
confirm: true,
select: "allowlist",
text: "c1, c2",
});
const result = await promptChannelAccessConfig({
prompter: prompter as any,
prompter,
label: "Slack",
});
@@ -136,12 +140,12 @@ describe("promptChannelAccessConfig skip flow", () => {
it("returns non-allowlist policy with empty entries", async () => {
const prompter = createPrompter({
confirm: async () => true,
select: async () => "open",
confirm: true,
select: "open",
});
const result = await promptChannelAccessConfig({
prompter: prompter as any,
prompter,
label: "Slack",
allowDisabled: true,
});
@@ -5,6 +5,7 @@ import {
resolveSetupWizardGroupAllowlist,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../../test/helpers/wizard-prompter.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
@@ -149,9 +150,12 @@ afterAll(() => {
});
function createPrompter(inputs: string[]) {
const text = vi.fn(async () => inputs.shift() ?? "");
const note = vi.fn(async () => undefined);
return {
text: vi.fn(async () => inputs.shift() ?? ""),
note: vi.fn(async () => undefined),
...createWizardPrompter(),
text,
note,
};
}
@@ -181,7 +185,6 @@ type AllowFromResolver = (params: {
token: string;
entries: string[];
}) => Promise<Array<{ input: string; resolved: boolean; id?: string | null }>>;
function asAllowFromResolver(resolveEntries: ReturnType<typeof vi.fn>): AllowFromResolver {
return resolveEntries as AllowFromResolver;
}
@@ -191,7 +194,7 @@ async function runPromptResolvedAllowFromWithToken(params: {
resolveEntries: AllowFromResolver;
}) {
return await promptResolvedAllowFrom({
prompter: params.prompter as any,
prompter: params.prompter,
existing: [],
token: "xoxb-test",
message: "msg",
@@ -229,11 +232,19 @@ function createSecretInputPrompter(params: {
const selects = [...params.selects];
const confirms = [...(params.confirms ?? [])];
const texts = [...(params.texts ?? [])];
const confirm = vi.fn(async () => confirms.shift() ?? false);
const text = vi.fn(async () => texts.shift() ?? "");
const note = vi.fn(async () => undefined);
const prompter = createWizardPrompter(undefined, {
defaultSelect: "plaintext",
selectValues: selects,
});
return {
select: vi.fn(async () => selects.shift() ?? "plaintext"),
confirm: vi.fn(async () => confirms.shift() ?? false),
text: vi.fn(async () => texts.shift() ?? ""),
note: vi.fn(async () => undefined),
...prompter,
select: vi.mocked(prompter.select),
confirm,
text,
note,
};
}
@@ -248,7 +259,7 @@ async function runPromptSingleChannelSecretInput(params: {
}) {
return await promptSingleChannelSecretInput({
cfg: {},
prompter: params.prompter as any,
prompter: params.prompter,
providerHint: params.providerHint,
credentialLabel: params.credentialLabel,
accountConfigured: params.accountConfigured,
@@ -310,7 +321,7 @@ async function runPromptLegacyAllowFrom(params: {
return await promptLegacyChannelAllowFrom({
cfg: params.cfg ?? {},
channel: params.channel,
prompter: params.prompter as any,
prompter: params.prompter,
existing: params.existing,
token: params.token,
noteTitle: params.noteTitle,
@@ -329,7 +340,7 @@ describe("promptResolvedAllowFrom", () => {
const resolveEntries = vi.fn();
const result = await promptResolvedAllowFrom({
prompter: prompter as any,
prompter,
existing: ["111"],
token: "",
message: "msg",
@@ -338,7 +349,9 @@ describe("promptResolvedAllowFrom", () => {
parseInputs: parseCsvInputs,
parseId: (value) => (/^\d+$/.test(value.trim()) ? value.trim() : null),
invalidWithoutTokenNote: "ids only",
resolveEntries: resolveEntries as any,
resolveEntries: resolveEntries as Parameters<
typeof promptResolvedAllowFrom
>[0]["resolveEntries"],
});
expect(result).toEqual(["111", "123"]);
@@ -442,7 +455,7 @@ describe("promptLegacyChannelAllowFromForAccount", () => {
},
} as OpenClawConfig,
channel: "slack",
prompter: prompter as any,
prompter,
defaultAccountId: DEFAULT_ACCOUNT_ID,
resolveAccount: () => ({
botToken: "xoxb-token",
@@ -810,7 +823,7 @@ describe("createPromptParsedAllowFromForAccount", () => {
},
},
},
prompter: prompter as any,
prompter,
});
expect(
@@ -839,7 +852,7 @@ describe("parsed allowFrom prompt builders", () => {
const prompter = createPrompter(["npub1"]);
const next = await promptAllowFrom({
cfg: {},
prompter: prompter as any,
prompter,
});
expect(next.channels?.nostr?.allowFrom).toEqual(["npub1"]);
@@ -859,7 +872,7 @@ describe("parsed allowFrom prompt builders", () => {
const next = await promptAllowFrom({
cfg: {},
prompter: createPrompter(["users/123"]) as any,
prompter: createPrompter(["users/123"]),
});
expect(next.channels?.googlechat?.enabled).toBe(true);
@@ -2060,7 +2073,7 @@ describe("resolveAccountIdForConfigure", () => {
it("uses normalized override without prompting", async () => {
const accountId = await resolveAccountIdForConfigure({
cfg: {},
prompter: {} as any,
prompter: createWizardPrompter(),
label: "Signal",
accountOverride: " Team Primary ",
shouldPromptAccountIds: true,
@@ -2073,7 +2086,7 @@ describe("resolveAccountIdForConfigure", () => {
it("uses default account when override is missing and prompting disabled", async () => {
const accountId = await resolveAccountIdForConfigure({
cfg: {},
prompter: {} as any,
prompter: createWizardPrompter(),
label: "Signal",
shouldPromptAccountIds: false,
listAccountIds: () => ["default"],
@@ -2083,15 +2096,15 @@ describe("resolveAccountIdForConfigure", () => {
});
it("prompts for account id when prompting is enabled and no override is provided", async () => {
const basePrompter = createWizardPrompter(undefined, { defaultSelect: "prompted-id" });
const prompter = {
select: vi.fn(async () => "prompted-id"),
text: vi.fn(async () => ""),
note: vi.fn(async () => undefined),
...basePrompter,
select: vi.mocked(basePrompter.select),
};
const accountId = await resolveAccountIdForConfigure({
cfg: {},
prompter: prompter as any,
prompter,
label: "Signal",
shouldPromptAccountIds: true,
listAccountIds: () => ["default", "prompted-id"],
+7 -25
View File
@@ -1,9 +1,9 @@
// Doctor repair flow tests cover repair plan output and repair execution.
import { describe, expect, it } from "vitest";
import { describe, expect, expectTypeOf, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runDoctorHealthRepairs } from "./doctor-repair-flow.js";
import { defineSplitHealthCheck, normalizeHealthCheck } from "./health-check-adapter.js";
import type { RunnableHealthCheck } from "./health-check-runner-types.js";
import type { RunnableHealthCheck, SplitHealthCheckInput } from "./health-check-runner-types.js";
import type { HealthCheck, HealthRepairContext } from "./health-checks.js";
function ctx(cfg: OpenClawConfig): HealthRepairContext {
@@ -107,29 +107,11 @@ describe("runDoctorHealthRepairs", () => {
});
it("keeps repairable out of split repair result types", () => {
const check = defineSplitHealthCheck({
id: "test/repair-result-status-boundary",
kind: "core",
description: "repair result status boundary",
async detect() {
return [
{
checkId: "test/repair-result-status-boundary",
severity: "warning",
message: "needs repair",
},
];
},
// @ts-expect-error repairable is a run-result preview status, not a split repair result.
async repair() {
return {
status: "repairable",
changes: [],
};
},
});
expect(check.id).toBe("test/repair-result-status-boundary");
type SplitRepair = NonNullable<SplitHealthCheckInput["repair"]>;
expectTypeOf(async () => ({
status: "repairable" as const,
changes: [],
})).not.toMatchTypeOf<SplitRepair>();
});
it("leaves non-repairable checks for legacy doctor behavior", async () => {
+1 -1
View File
@@ -1830,7 +1830,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
mode: "token",
token: "secret",
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false },
} as any;
};
await withGatewayServer(
async ({ port }) => {
const headers = {
@@ -2556,12 +2556,12 @@ describe("timestampOptsFromConfig", () => {
it.each([
{
name: "extracts timezone from config",
cfg: { agents: { defaults: { userTimezone: "America/Chicago" } } } as any,
cfg: { agents: { defaults: { userTimezone: "America/Chicago" } } } as OpenClawConfig,
expected: "America/Chicago",
},
{
name: "falls back gracefully with empty config",
cfg: {} as any,
cfg: {} as OpenClawConfig,
expected: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
},
])("$name", ({ cfg, expected }) => {
+1 -1
View File
@@ -1007,7 +1007,7 @@ describe("sanitizeHostExecEnv", () => {
baseEnv: {
PATH: "/usr/bin:/bin",
GOOD: "1",
BAD_NUMBER: 1 as any,
BAD_NUMBER: 1 as unknown as string,
"NOT-PORTABLE": "x",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
},
+7 -1
View File
@@ -25,7 +25,13 @@ describe("path prepend helpers", () => {
it("normalizes prepend lists by trimming, skipping blanks, and deduping", () => {
expect(
normalizePathPrepend([" /custom/bin ", "", " /custom/bin ", "/opt/bin", 42 as any]),
normalizePathPrepend([
" /custom/bin ",
"",
" /custom/bin ",
"/opt/bin",
42 as unknown as string,
]),
).toEqual(["/custom/bin", "/opt/bin"]);
expect(normalizePathPrepend()).toStrictEqual([]);
});
+6 -6
View File
@@ -6,42 +6,42 @@ describe("readSqliteUserVersion", () => {
it("returns 0 when row is undefined", () => {
const db = {
prepare: () => ({ get: () => undefined }),
} as any;
};
expect(readSqliteUserVersion(db)).toBe(0);
});
it("returns 0 when user_version is null", () => {
const db = {
prepare: () => ({ get: () => ({ user_version: null }) }),
} as any;
};
expect(readSqliteUserVersion(db)).toBe(0);
});
it("returns numeric user_version", () => {
const db = {
prepare: () => ({ get: () => ({ user_version: 5 }) }),
} as any;
};
expect(readSqliteUserVersion(db)).toBe(5);
});
it("returns 0 when user_version is 0", () => {
const db = {
prepare: () => ({ get: () => ({ user_version: 0 }) }),
} as any;
};
expect(readSqliteUserVersion(db)).toBe(0);
});
it("converts string user_version to number", () => {
const db = {
prepare: () => ({ get: () => ({ user_version: "3" }) }),
} as any;
};
expect(readSqliteUserVersion(db)).toBe(3);
});
it("returns 0 for empty object", () => {
const db = {
prepare: () => ({ get: () => ({}) }),
} as any;
};
expect(readSqliteUserVersion(db)).toBe(0);
});
});
+4 -2
View File
@@ -1,6 +1,8 @@
import type { DatabaseSync } from "node:sqlite";
type SqliteUserVersionReader = {
prepare: (sql: string) => { get: () => unknown };
};
export function readSqliteUserVersion(db: DatabaseSync): number {
export function readSqliteUserVersion(db: SqliteUserVersionReader): number {
const row = db.prepare("PRAGMA user_version").get() as { user_version?: unknown } | undefined;
return Number(row?.user_version ?? 0);
}
+3 -2
View File
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js";
import type { GatewayClientOptions } from "../gateway/client.js";
import type { ensureNodeHostConfig } from "./config.js";
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import {
resolveNodeHostGatewayDeviceFamily,
@@ -490,7 +491,7 @@ describe("runNodeHost", () => {
version: 1,
nodeId: "node-test",
gateway: { contextPath: "/old-path" },
} as any);
} as Awaited<ReturnType<typeof ensureNodeHostConfig>>);
await expect(
runNodeHost({
@@ -510,7 +511,7 @@ describe("runNodeHost", () => {
version: 1,
nodeId: "node-test",
gateway: { contextPath: "/old-path" },
} as any);
} as Awaited<ReturnType<typeof ensureNodeHostConfig>>);
await expect(
runNodeHost({
+16 -15
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest";
import { loadTranscriptEvents } from "../../config/sessions/session-accessor.js";
import { createGatewaySession } from "../../gateway/session-create-service.js";
import {
@@ -20,21 +20,22 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
return { promise, resolve };
}
function assertRecoveryInitializerTypeContract(
create: ReturnType<typeof createRuntimeAgent>["session"]["createSessionEntry"],
): void {
// @ts-expect-error Recovery must return the final trusted plugin extension patch.
void create({
cfg: {},
key: "type-contract-only",
recoverMatchingInitialEntry: true,
initialEntry: { agentHarnessId: "codex" },
afterCreate: async () => {},
});
}
void assertRecoveryInitializerTypeContract;
describe("plugin runtime session creation", () => {
it("requires recovery initialization to return the final trusted patch", () => {
type CreateSessionParams = Parameters<
ReturnType<typeof createRuntimeAgent>["session"]["createSessionEntry"]
>[0];
const invalidRecoveryInitializer = {
cfg: {},
key: "type-contract-only",
recoverMatchingInitialEntry: true as const,
initialEntry: { agentHarnessId: "codex" },
afterCreate: async () => {},
};
expectTypeOf(invalidRecoveryInitializer).not.toMatchTypeOf<CreateSessionParams>();
});
it("creates a canonical transcript with trusted initial session state", async () => {
await withOpenClawTestState({ label: "plugin-runtime-session-create" }, async () => {
const runtime = createRuntimeAgent();
+1 -1
View File
@@ -6,7 +6,7 @@ const theme = {
dim: (s: string) => `<d>${s}</d>`,
bold: (s: string) => `<b>${s}</b>`,
accentSoft: (s: string) => `<a>${s}</a>`,
} as any;
} satisfies Parameters<typeof buildWaitingStatusMessage>[0]["theme"];
describe("tui-waiting", () => {
it("pickWaitingPhrase rotates every 10 ticks", () => {
+3 -2
View File
@@ -7,10 +7,11 @@ import type { WizardPrompter } from "../../src/wizard/prompts.js";
/** Create a WizardPrompter with default mocked responses and optional overrides. */
export function createWizardPrompter(
overrides?: Partial<WizardPrompter>,
options?: { defaultSelect?: string },
options?: { defaultSelect?: string; selectValues?: string[] },
): WizardPrompter {
const selectValues = [...(options?.selectValues ?? [])];
const select = vi.fn(
async () => options?.defaultSelect ?? "quickstart",
async () => selectValues.shift() ?? options?.defaultSelect ?? "quickstart",
) as unknown as WizardPrompter["select"];
return {
intro: vi.fn(async () => {}),
@@ -0,0 +1,73 @@
// Type Suppression Inventory tests cover AST detection and the repository suppression ratchet.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { collectTypeSuppressionReport } from "../../scripts/type-suppression-inventory.js";
const repoRoot = path.resolve(import.meta.dirname, "../..");
const temporaryDirectories: string[] = [];
function createFixture(files: Record<string, string>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-type-suppressions-"));
temporaryDirectories.push(root);
for (const [relativePath, source] of Object.entries(files)) {
const absolutePath = path.join(root, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, source);
}
return root;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { force: true, recursive: true });
}
});
describe("type suppression inventory", () => {
it("detects syntax suppressions without counting prose", () => {
const fixtureRoot = createFixture({
"src/example.ts": `
const prose = "as any and @ts-expect-error";
const first = value as any;
const second = <any>value;
// @ts-expect-error invalid contract fixture
consume({ invalid: true });
`,
});
const report = collectTypeSuppressionReport({
files: ["src/example.ts"],
repoRoot: fixtureRoot,
});
expect(report.summary).toMatchObject({
findingCount: 3,
kindCounts: {
"as-any": 1,
"expect-error": 1,
"type-assertion-any": 1,
},
touchedFileCount: 1,
});
});
it("keeps unchecked any casts at zero and negative type assertions explicit", () => {
const report = collectTypeSuppressionReport({ repoRoot });
expect(report.summary.kindCounts["as-any"]).toBe(0);
expect(report.summary.kindCounts["type-assertion-any"]).toBe(0);
expect(
report.findings
.filter((finding) => finding.kind === "expect-error")
.map((finding) => `${finding.file}:${finding.line}:${finding.excerpt}`),
).toEqual([
"src/infra/kysely-sync.types.test.ts:49:@ts-expect-error Kysely checks selected column string literals.",
"src/infra/kysely-sync.types.test.ts:52:@ts-expect-error Kysely checks table string literals.",
"src/infra/kysely-sync.types.test.ts:55:@ts-expect-error Kysely checks where-reference string literals.",
"src/infra/kysely-sync.types.test.ts:58:@ts-expect-error Kysely checks grouped column string literals.",
"src/infra/kysely-sync.types.test.ts:61:@ts-expect-error Kysely checks order references and selected aliases.",
]);
});
});