mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 17:31:31 +08:00
Compare commits
6 Commits
codex/red-
...
feat/qmd-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc562fc4e2 | ||
|
|
6f9c1bdb62 | ||
|
|
448b7c75b6 | ||
|
|
6830aa39ea | ||
|
|
a0b397748f | ||
|
|
dd0e4f6e61 |
@@ -1,2 +1,2 @@
|
||||
abdff20b710c6b0fecb5af25603d7cfad7ade80600ca374ebe38f69d78933b50 plugin-sdk-api-baseline.json
|
||||
630367961e4d14463020f588564c23308159ae2de6e4301418b2b0c471797e70 plugin-sdk-api-baseline.jsonl
|
||||
44b4a023f039e30152a67e9e985c32eda856afc62135d70cf856f38b37f9b4ef plugin-sdk-api-baseline.json
|
||||
2e79ac4b7c44b892c53994c0af2ef5c04b4f99902303bd6d2369c390b2ab7859 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -855,7 +855,7 @@ describe("google-meet plugin", () => {
|
||||
});
|
||||
|
||||
it("registers the node-host command used by chrome-node transport", () => {
|
||||
const { nodeHostCommands } = setup();
|
||||
const { nodeHostCommands, nodeInvokePolicies } = setup();
|
||||
|
||||
const command = nodeHostCommands.find(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
@@ -865,7 +865,13 @@ describe("google-meet plugin", () => {
|
||||
throw new Error("expected googlemeet.chrome node host command");
|
||||
}
|
||||
expect(command.cap).toBe("google-meet");
|
||||
expect(command.dangerous).toBe(true);
|
||||
expect(typeof command.handle).toBe("function");
|
||||
expect(nodeInvokePolicies).toHaveLength(1);
|
||||
expect(nodeInvokePolicies[0]).toMatchObject({
|
||||
commands: ["googlemeet.chrome"],
|
||||
dangerous: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome talk-back joins", async () => {
|
||||
@@ -2239,6 +2245,9 @@ describe("google-meet plugin", () => {
|
||||
try {
|
||||
const { methods, runCommandWithTimeout } = setup({
|
||||
defaultMode: "transcribe",
|
||||
chrome: {
|
||||
browserProfile: "meet-devtools",
|
||||
},
|
||||
});
|
||||
const callGatewayFromCli = mockLocalMeetBrowserRequest({
|
||||
inCall: true,
|
||||
@@ -3428,7 +3437,12 @@ describe("google-meet plugin", () => {
|
||||
},
|
||||
);
|
||||
chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
|
||||
const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
|
||||
const { tools, nodesInvoke } = setup({
|
||||
defaultTransport: "chrome",
|
||||
chrome: {
|
||||
browserProfile: "meet-devtools",
|
||||
},
|
||||
});
|
||||
const tool = tools[0] as {
|
||||
execute: (
|
||||
id: string,
|
||||
@@ -3458,6 +3472,7 @@ describe("google-meet plugin", () => {
|
||||
expect(focusCall[0]).toBe("browser.request");
|
||||
expect(requireRecord(focusCall[2], "focus request").method).toBe("POST");
|
||||
expect(requireRecord(focusCall[2], "focus request").path).toBe("/tabs/focus");
|
||||
expect(requireRecord(focusCall[2], "focus request").query).toBeUndefined();
|
||||
expect(focusCall[3]).toEqual({ progress: false });
|
||||
expect(nodesInvoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
fetchGoogleMeetSpace,
|
||||
} from "./src/meet.js";
|
||||
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
|
||||
import {
|
||||
createGoogleMeetChromeNodeInvokePolicy,
|
||||
GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
} from "./src/node-invoke-policy.js";
|
||||
import { GoogleMeetRuntime } from "./src/runtime.js";
|
||||
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
|
||||
|
||||
@@ -1196,10 +1200,12 @@ export default definePluginEntry({
|
||||
);
|
||||
|
||||
api.registerNodeHostCommand({
|
||||
command: "googlemeet.chrome",
|
||||
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
cap: "google-meet",
|
||||
dangerous: true,
|
||||
handle: handleGoogleMeetNodeHostCommand,
|
||||
});
|
||||
api.registerNodeInvokePolicy(createGoogleMeetChromeNodeInvokePolicy(config));
|
||||
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
|
||||
@@ -91,6 +91,41 @@ describe("google-meet node host bridge sessions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the Meet URL before Chrome profile args when launching a profiled browser", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
children.length = 0;
|
||||
vi.mocked(spawnSync).mockClear();
|
||||
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
|
||||
try {
|
||||
const start = JSON.parse(
|
||||
await handleGoogleMeetNodeHostCommand(
|
||||
JSON.stringify({
|
||||
action: "start",
|
||||
url: "https://meet.google.com/xyz-abcd-uvw",
|
||||
mode: "transcribe",
|
||||
browserProfile: "Profile 2",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(start.launched).toBe(true);
|
||||
expect(spawnSync).toHaveBeenCalledWith(
|
||||
"open",
|
||||
[
|
||||
"-a",
|
||||
"Google Chrome",
|
||||
"https://meet.google.com/xyz-abcd-uvw",
|
||||
"--args",
|
||||
"--profile-directory=Profile 2",
|
||||
],
|
||||
expect.objectContaining({ encoding: "utf8" }),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
it("clears output playback without closing the active bridge when the old output exits", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
children.length = 0;
|
||||
|
||||
@@ -332,12 +332,11 @@ function startChrome(params: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
if (params.launch !== false) {
|
||||
const argv = ["open", "-a", "Google Chrome"];
|
||||
const argv = ["open", "-a", "Google Chrome", url];
|
||||
const browserProfile = readString(params.browserProfile);
|
||||
if (browserProfile) {
|
||||
argv.push("--args", `--profile-directory=${browserProfile}`);
|
||||
}
|
||||
argv.push(url);
|
||||
const result = runCommandWithTimeout(argv, timeoutMs);
|
||||
if (result.code !== 0) {
|
||||
if (bridgeId) {
|
||||
|
||||
134
extensions/google-meet/src/node-invoke-policy.test.ts
Normal file
134
extensions/google-meet/src/node-invoke-policy.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Google Meet node.invoke policy tests cover caller-controlled command sanitization.
|
||||
import type { OpenClawPluginNodeInvokePolicyContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveGoogleMeetConfig } from "./config.js";
|
||||
import {
|
||||
createGoogleMeetChromeNodeInvokePolicy,
|
||||
GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
} from "./node-invoke-policy.js";
|
||||
|
||||
function createContext(params: unknown, pluginConfig: Record<string, unknown> = {}) {
|
||||
const invokeNode = vi.fn<OpenClawPluginNodeInvokePolicyContext["invokeNode"]>(async () => ({
|
||||
ok: true,
|
||||
payload: { ok: true },
|
||||
}));
|
||||
const ctx: OpenClawPluginNodeInvokePolicyContext = {
|
||||
nodeId: "node-1",
|
||||
command: GOOGLE_MEET_CHROME_NODE_COMMAND,
|
||||
params,
|
||||
config: {} as never,
|
||||
pluginConfig,
|
||||
invokeNode,
|
||||
};
|
||||
return { ctx, invokeNode };
|
||||
}
|
||||
|
||||
describe("Google Meet node invoke policy", () => {
|
||||
it("rewrites start executable fields from trusted config", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(
|
||||
resolveGoogleMeetConfig({
|
||||
chrome: {
|
||||
launch: false,
|
||||
browserProfile: "Trusted Profile",
|
||||
joinTimeoutMs: 45_000,
|
||||
audioInputCommand: ["trusted-capture", "--raw"],
|
||||
audioOutputCommand: ["trusted-play", "--raw"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "start",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
mode: "bidi",
|
||||
launch: true,
|
||||
browserProfile: "Attacker Profile",
|
||||
joinTimeoutMs: 1,
|
||||
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
|
||||
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
|
||||
audioInputCommand: ["malicious-capture"],
|
||||
audioOutputCommand: ["malicious-play"],
|
||||
});
|
||||
|
||||
await expect(policy.handle(ctx)).resolves.toEqual({ ok: true, payload: { ok: true } });
|
||||
|
||||
expect(invokeNode).toHaveBeenCalledTimes(1);
|
||||
expect(invokeNode).toHaveBeenCalledWith({
|
||||
params: {
|
||||
action: "start",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
mode: "bidi",
|
||||
launch: false,
|
||||
browserProfile: "Trusted Profile",
|
||||
joinTimeoutMs: 45_000,
|
||||
audioInputCommand: ["trusted-capture", "--raw"],
|
||||
audioOutputCommand: ["trusted-play", "--raw"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses trusted configured external bridge commands for start", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(
|
||||
resolveGoogleMeetConfig({
|
||||
chrome: {
|
||||
audioBridgeHealthCommand: ["trusted-bridge", "status"],
|
||||
audioBridgeCommand: ["trusted-bridge", "start"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "start",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
mode: "bidi",
|
||||
audioBridgeHealthCommand: ["node", "-e", "process.exit(98)"],
|
||||
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
|
||||
});
|
||||
|
||||
await policy.handle(ctx);
|
||||
|
||||
const call = invokeNode.mock.calls[0]?.[0];
|
||||
expect(call?.params).toMatchObject({
|
||||
action: "start",
|
||||
audioBridgeHealthCommand: ["trusted-bridge", "status"],
|
||||
audioBridgeCommand: ["trusted-bridge", "start"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects direct start for non-Meet URLs before node dispatch", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "start",
|
||||
url: "https://example.com/private",
|
||||
mode: "bidi",
|
||||
});
|
||||
|
||||
await expect(policy.handle(ctx)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
|
||||
message: "url must be an explicit https://meet.google.com/... URL",
|
||||
});
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps direct setup diagnostics but strips extra fields", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
|
||||
const { ctx, invokeNode } = createContext({
|
||||
action: "setup",
|
||||
audioBridgeCommand: ["node", "-e", "process.exit(99)"],
|
||||
});
|
||||
|
||||
await policy.handle(ctx);
|
||||
|
||||
expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup" } });
|
||||
});
|
||||
|
||||
it("rejects unsupported googlemeet.chrome actions before node dispatch", async () => {
|
||||
const policy = createGoogleMeetChromeNodeInvokePolicy(resolveGoogleMeetConfig({}));
|
||||
const { ctx, invokeNode } = createContext({ action: "exec", command: ["id"] });
|
||||
|
||||
await expect(policy.handle(ctx)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: "GOOGLE_MEET_NODE_POLICY_DENIED",
|
||||
});
|
||||
expect(invokeNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
192
extensions/google-meet/src/node-invoke-policy.ts
Normal file
192
extensions/google-meet/src/node-invoke-policy.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
OpenClawPluginNodeInvokePolicyContext,
|
||||
OpenClawPluginNodeInvokePolicyResult,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { GoogleMeetConfig } from "./config.js";
|
||||
import { normalizeMeetUrl } from "./runtime.js";
|
||||
|
||||
export const GOOGLE_MEET_CHROME_NODE_COMMAND = "googlemeet.chrome";
|
||||
|
||||
const START_MODES = new Set(["agent", "bidi", "realtime", "transcribe"]);
|
||||
|
||||
type PolicyDecision =
|
||||
| { approved: true; params: Record<string, unknown> }
|
||||
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readPositiveNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function copyCommand(command: string[] | undefined): string[] | undefined {
|
||||
return command && command.length > 0 ? [...command] : undefined;
|
||||
}
|
||||
|
||||
function denied(message: string, code = "GOOGLE_MEET_NODE_POLICY_DENIED") {
|
||||
return { ok: false as const, code, message };
|
||||
}
|
||||
|
||||
function approved(params: Record<string, unknown>): PolicyDecision {
|
||||
return { approved: true, params };
|
||||
}
|
||||
|
||||
function buildStartParams(
|
||||
params: Record<string, unknown>,
|
||||
config: GoogleMeetConfig,
|
||||
): PolicyDecision {
|
||||
let url: string;
|
||||
try {
|
||||
url = normalizeMeetUrl(params.url);
|
||||
} catch (error) {
|
||||
return {
|
||||
approved: false,
|
||||
result: denied(
|
||||
error instanceof Error ? error.message : "googlemeet.chrome start requires url",
|
||||
),
|
||||
};
|
||||
}
|
||||
const mode = readString(params.mode);
|
||||
if (mode && !START_MODES.has(mode)) {
|
||||
return {
|
||||
approved: false,
|
||||
result: denied(`googlemeet.chrome start mode is unsupported: ${mode}`),
|
||||
};
|
||||
}
|
||||
const startParams: Record<string, unknown> = {
|
||||
action: "start",
|
||||
url,
|
||||
launch: params.launch === false ? false : config.chrome.launch,
|
||||
browserProfile: config.chrome.browserProfile,
|
||||
joinTimeoutMs: config.chrome.joinTimeoutMs,
|
||||
};
|
||||
if (mode) {
|
||||
startParams.mode = mode;
|
||||
}
|
||||
const audioInputCommand = copyCommand(config.chrome.audioInputCommand);
|
||||
if (audioInputCommand) {
|
||||
startParams.audioInputCommand = audioInputCommand;
|
||||
}
|
||||
const audioOutputCommand = copyCommand(config.chrome.audioOutputCommand);
|
||||
if (audioOutputCommand) {
|
||||
startParams.audioOutputCommand = audioOutputCommand;
|
||||
}
|
||||
const audioBridgeCommand = copyCommand(config.chrome.audioBridgeCommand);
|
||||
if (audioBridgeCommand) {
|
||||
startParams.audioBridgeCommand = audioBridgeCommand;
|
||||
}
|
||||
const audioBridgeHealthCommand = copyCommand(config.chrome.audioBridgeHealthCommand);
|
||||
if (audioBridgeHealthCommand) {
|
||||
startParams.audioBridgeHealthCommand = audioBridgeHealthCommand;
|
||||
}
|
||||
return approved(startParams);
|
||||
}
|
||||
|
||||
function buildForwardParams(params: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const action = readString(params.action);
|
||||
switch (action) {
|
||||
case "setup":
|
||||
return { action };
|
||||
case "status": {
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
return bridgeId ? { action, bridgeId } : { action };
|
||||
}
|
||||
case "list": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const url = readString(params.url);
|
||||
const mode = readString(params.mode);
|
||||
if (url) {
|
||||
forwarded.url = url;
|
||||
}
|
||||
if (mode) {
|
||||
forwarded.mode = mode;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "stopByUrl": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const url = readString(params.url);
|
||||
const mode = readString(params.mode);
|
||||
const exceptBridgeId = readString(params.exceptBridgeId);
|
||||
if (url) {
|
||||
forwarded.url = url;
|
||||
}
|
||||
if (mode) {
|
||||
forwarded.mode = mode;
|
||||
}
|
||||
if (exceptBridgeId) {
|
||||
forwarded.exceptBridgeId = exceptBridgeId;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "pullAudio": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
const timeoutMs = readPositiveNumber(params.timeoutMs);
|
||||
if (bridgeId) {
|
||||
forwarded.bridgeId = bridgeId;
|
||||
}
|
||||
if (timeoutMs) {
|
||||
forwarded.timeoutMs = timeoutMs;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "pushAudio": {
|
||||
const forwarded: Record<string, unknown> = { action };
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
const base64 = readString(params.base64);
|
||||
if (bridgeId) {
|
||||
forwarded.bridgeId = bridgeId;
|
||||
}
|
||||
if (base64) {
|
||||
forwarded.base64 = base64;
|
||||
}
|
||||
return forwarded;
|
||||
}
|
||||
case "clearAudio":
|
||||
case "stop": {
|
||||
const bridgeId = readString(params.bridgeId);
|
||||
return bridgeId ? { action, bridgeId } : { action };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createGoogleMeetChromeNodeInvokePolicy(
|
||||
config: GoogleMeetConfig,
|
||||
): OpenClawPluginNodeInvokePolicy {
|
||||
return {
|
||||
commands: [GOOGLE_MEET_CHROME_NODE_COMMAND],
|
||||
dangerous: true,
|
||||
async handle(ctx: OpenClawPluginNodeInvokePolicyContext) {
|
||||
if (ctx.command !== GOOGLE_MEET_CHROME_NODE_COMMAND) {
|
||||
return denied(`unsupported Google Meet node command: ${ctx.command}`);
|
||||
}
|
||||
const params = asRecord(ctx.params);
|
||||
const action = readString(params.action);
|
||||
let decision: PolicyDecision;
|
||||
if (action === "start") {
|
||||
decision = buildStartParams(params, config);
|
||||
} else {
|
||||
const forwardParams = buildForwardParams(params);
|
||||
decision = forwardParams
|
||||
? approved(forwardParams)
|
||||
: { approved: false, result: denied("unsupported googlemeet.chrome action") };
|
||||
}
|
||||
if (!decision.approved) {
|
||||
return decision.result;
|
||||
}
|
||||
return await ctx.invokeNode({ params: decision.params });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export function setupGoogleMeetPlugin(
|
||||
const tools: unknown[] = [];
|
||||
const cliRegistrations: unknown[] = [];
|
||||
const nodeHostCommands: unknown[] = [];
|
||||
const nodeInvokePolicies: unknown[] = [];
|
||||
const nodesList = vi.fn(
|
||||
async () =>
|
||||
options.nodesListResult ?? {
|
||||
@@ -165,6 +166,7 @@ export function setupGoogleMeetPlugin(
|
||||
},
|
||||
registerCli: (_registrar: unknown, opts: unknown) => cliRegistrations.push(opts),
|
||||
registerNodeHostCommand: (command: unknown) => nodeHostCommands.push(command),
|
||||
registerNodeInvokePolicy: (policy: unknown) => nodeInvokePolicies.push(policy),
|
||||
});
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
@@ -184,6 +186,7 @@ export function setupGoogleMeetPlugin(
|
||||
nodesList,
|
||||
nodesInvoke,
|
||||
nodeHostCommands,
|
||||
nodeInvokePolicies,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,4 +24,4 @@ export {
|
||||
listMemoryFiles,
|
||||
normalizeExtraMemoryPaths,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
export { getMemorySearchManager } from "./memory/index.js";
|
||||
export { getMemorySearchManager } from "openclaw/plugin-sdk/memory-core-engine-runtime";
|
||||
|
||||
@@ -199,11 +199,17 @@ vi.mock("openclaw/plugin-sdk/file-lock", async () => {
|
||||
import { spawn as mockedSpawn } from "node:child_process";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import {
|
||||
type MemorySearchRuntimeDebug,
|
||||
requireNodeSqlite,
|
||||
resolveMemoryBackendConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { formatSessionTranscriptMemoryHitKey } from "openclaw/plugin-sdk/session-transcript-hit";
|
||||
import {
|
||||
configureMemoryCoreDreamingState,
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../dreaming-state.js";
|
||||
import { resolveQmdSessionArtifactIdentity } from "../qmd-session-artifacts.js";
|
||||
import { QmdMemoryManager, resolveQmdMcporterSearchProcessTimeoutMs } from "./qmd-manager.js";
|
||||
|
||||
@@ -257,6 +263,14 @@ describe("QmdMemoryManager", () => {
|
||||
return mock.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
}
|
||||
|
||||
function qmdCommandCalls(): string[][] {
|
||||
return spawnMock.mock.calls.map((call: unknown[]) => call[1] as string[]);
|
||||
}
|
||||
|
||||
function countQmdCommand(predicate: (args: string[]) => boolean): number {
|
||||
return qmdCommandCalls().filter(predicate).length;
|
||||
}
|
||||
|
||||
function expectMockMessageContains(mock: Mock, text: string): void {
|
||||
expect(mockMessages(mock).join("\n")).toContain(text);
|
||||
}
|
||||
@@ -277,6 +291,387 @@ describe("QmdMemoryManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses persisted collection validation across transient cli managers", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
const first = await createManager({ mode: "cli" });
|
||||
await first.manager.close();
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
|
||||
|
||||
spawnMock.mockClear();
|
||||
const second = await createManager({ mode: "cli" });
|
||||
await second.manager.close();
|
||||
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(0);
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "show")).toBe(0);
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(0);
|
||||
});
|
||||
|
||||
it("does not cache incomplete collection validation", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "collection" && args[1] === "add") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stderr", "permission denied", 1);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const first = await createManager({ mode: "cli" });
|
||||
await first.manager.close();
|
||||
|
||||
spawnMock.mockClear();
|
||||
spawnMock.mockImplementation(() => createMockChild());
|
||||
const second = await createManager({ mode: "cli" });
|
||||
await second.manager.close();
|
||||
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
|
||||
});
|
||||
|
||||
it("runs collection validation when the runtime cache store is unavailable", async () => {
|
||||
configureMemoryCoreDreamingState(() => {
|
||||
throw new Error("state store unavailable");
|
||||
});
|
||||
try {
|
||||
const manager = await createManager({ mode: "cli" });
|
||||
await manager.manager.close();
|
||||
} finally {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
}
|
||||
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "add")).toBe(1);
|
||||
});
|
||||
|
||||
it("reports collection validation debug only once per validation run", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
const { manager } = await createManager({ mode: "cli" });
|
||||
const firstDebug: MemorySearchRuntimeDebug[] = [];
|
||||
const secondDebug: MemorySearchRuntimeDebug[] = [];
|
||||
|
||||
await manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
firstDebug.push(entry);
|
||||
},
|
||||
});
|
||||
await manager.search("fact again", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
secondDebug.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstDebug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("write");
|
||||
expect(secondDebug.at(-1)?.qmd?.collectionValidation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("misses collection validation cache when managed collection config changes", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
const first = await createManager({ mode: "cli" });
|
||||
await first.manager.close();
|
||||
|
||||
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
|
||||
await fs.mkdir(otherWorkspaceDir, { recursive: true });
|
||||
const changedCfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
...(cfg.memory?.qmd ?? {}),
|
||||
paths: [{ path: otherWorkspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
spawnMock.mockClear();
|
||||
const second = await createManager({ mode: "cli", cfg: changedCfg });
|
||||
await second.manager.close();
|
||||
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
|
||||
});
|
||||
|
||||
it("bypasses validation cache for missing-collection search repair", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
const { manager } = await createManager();
|
||||
spawnMock.mockClear();
|
||||
let searchAttempts = 0;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "query" || args[0] === "search" || args[0] === "vsearch") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
searchAttempts += 1;
|
||||
if (searchAttempts === 1) {
|
||||
emitAndClose(child, "stderr", "collection workspace-main not found", 1);
|
||||
} else {
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
}
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
const debug: MemorySearchRuntimeDebug[] = [];
|
||||
|
||||
await manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
debug.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
expect(searchAttempts).toBe(2);
|
||||
expect(countQmdCommand((args) => args[0] === "collection" && args[1] === "list")).toBe(1);
|
||||
expect(debug.at(-1)?.qmd?.collectionValidation?.cacheState).toBe("bypass-force");
|
||||
});
|
||||
|
||||
it("reuses persisted qmd multi-collection support probe across managers", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
sessions: { enabled: true },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "--help") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const first = await createManager({ mode: "cli" });
|
||||
await first.manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
});
|
||||
await first.manager.close();
|
||||
expect(countQmdCommand((args) => args[0] === "--help")).toBe(1);
|
||||
|
||||
spawnMock.mockClear();
|
||||
const second = await createManager({ mode: "cli" });
|
||||
const debug: MemorySearchRuntimeDebug[] = [];
|
||||
await second.manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
debug.push(entry);
|
||||
},
|
||||
});
|
||||
await second.manager.close();
|
||||
|
||||
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
|
||||
expect(debug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("hit");
|
||||
expect(debug.at(-1)?.qmd?.searchPlan?.groupCount).toBe(2);
|
||||
});
|
||||
|
||||
it("reports multi-collection probe debug only when the probe runs", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
sessions: { enabled: true },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "--help") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
const { manager } = await createManager({ mode: "cli" });
|
||||
const firstDebug: MemorySearchRuntimeDebug[] = [];
|
||||
const secondDebug: MemorySearchRuntimeDebug[] = [];
|
||||
|
||||
await manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
firstDebug.push(entry);
|
||||
},
|
||||
});
|
||||
await manager.search("fact again", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
secondDebug.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe?.cacheState).toBe("write");
|
||||
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps concurrent search debug isolated on a shared qmd manager", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
sessions: { enabled: true },
|
||||
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
let firstSearchChild: MockChild | undefined;
|
||||
let searchCalls = 0;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
searchCalls += 1;
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (searchCalls === 1) {
|
||||
firstSearchChild = child;
|
||||
return child;
|
||||
}
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "--version") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "qmd 1.0.0");
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
const firstDebug: MemorySearchRuntimeDebug[] = [];
|
||||
const secondDebug: MemorySearchRuntimeDebug[] = [];
|
||||
|
||||
const firstSearch = manager.search("memory fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
sources: ["memory"],
|
||||
onDebug: (entry) => {
|
||||
firstDebug.push(entry);
|
||||
},
|
||||
});
|
||||
await waitUntil(() => searchCalls === 1);
|
||||
const secondSearch = manager.search("session fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
sources: ["sessions"],
|
||||
onDebug: (entry) => {
|
||||
secondDebug.push(entry);
|
||||
},
|
||||
});
|
||||
await waitUntil(() => searchCalls === 2);
|
||||
emitAndClose(requireValue(firstSearchChild, "first search child missing"), "stdout", "[]");
|
||||
|
||||
await Promise.all([firstSearch, secondSearch]);
|
||||
|
||||
expect(firstDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["memory"]);
|
||||
expect(secondDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["sessions"]);
|
||||
});
|
||||
|
||||
it("rewrites stale multi-collection probe cache when combined filters are rejected", async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
const otherWorkspaceDir = path.join(tmpRoot, "other-workspace");
|
||||
await fs.mkdir(otherWorkspaceDir, { recursive: true });
|
||||
cfg = {
|
||||
...cfg,
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
|
||||
paths: [
|
||||
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
|
||||
{ path: otherWorkspaceDir, pattern: "**/*.md", name: "other" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const isCombinedSearch = (args: string[]) =>
|
||||
(args[0] === "search" || args[0] === "query") &&
|
||||
args.filter((token) => token === "-c").length > 1;
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "--version") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "qmd 1.0.0");
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "--help") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "Usage: qmd search -c one or more collections");
|
||||
return child;
|
||||
}
|
||||
if (isCombinedSearch(args)) {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stderr", "unknown flag: -c", 1);
|
||||
return child;
|
||||
}
|
||||
if (args[0] === "search" || args[0] === "query" || args[0] === "vsearch") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(child, "stdout", "[]");
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const first = await createManager({ mode: "cli" });
|
||||
const firstDebug: MemorySearchRuntimeDebug[] = [];
|
||||
await first.manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
firstDebug.push(entry);
|
||||
},
|
||||
});
|
||||
await first.manager.close();
|
||||
|
||||
expect(firstDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({
|
||||
cacheState: "write",
|
||||
supported: false,
|
||||
});
|
||||
|
||||
spawnMock.mockClear();
|
||||
const second = await createManager({ mode: "cli" });
|
||||
const secondDebug: MemorySearchRuntimeDebug[] = [];
|
||||
await second.manager.search("fact", {
|
||||
sessionKey: "agent:main:slack:dm:u123",
|
||||
onDebug: (entry) => {
|
||||
secondDebug.push(entry);
|
||||
},
|
||||
});
|
||||
await second.manager.close();
|
||||
|
||||
expect(countQmdCommand((args) => args[0] === "--help")).toBe(0);
|
||||
expect(countQmdCommand(isCombinedSearch)).toBe(0);
|
||||
expect(secondDebug.at(-1)?.qmd?.multiCollectionProbe).toMatchObject({
|
||||
cacheState: "hit",
|
||||
supported: false,
|
||||
});
|
||||
});
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.lstat(targetPath);
|
||||
@@ -406,6 +801,7 @@ describe("QmdMemoryManager", () => {
|
||||
delete (globalThis as Record<PropertyKey, unknown>)[MCPORTER_STATE_KEY];
|
||||
delete (globalThis as Record<PropertyKey, unknown>)[QMD_EMBED_QUEUE_KEY];
|
||||
delete (globalThis as Record<PropertyKey, unknown>)[MEMORY_EMBEDDING_PROVIDERS_KEY];
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
});
|
||||
|
||||
it("debounces back-to-back sync calls", async () => {
|
||||
|
||||
@@ -74,6 +74,16 @@ import {
|
||||
type QmdSessionArtifactMapping,
|
||||
} from "../qmd-session-artifacts.js";
|
||||
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
|
||||
import {
|
||||
clearQmdMultiCollectionProbeCache,
|
||||
readQmdCollectionValidationCache,
|
||||
readQmdMultiCollectionProbeCache,
|
||||
writeQmdCollectionValidationCache,
|
||||
writeQmdMultiCollectionProbeCache,
|
||||
type QmdRuntimeCollectionValidationCacheContext,
|
||||
type QmdRuntimeManagedCollection,
|
||||
type QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
} from "./qmd-runtime-cache.js";
|
||||
import {
|
||||
countChokidarWatchedEntries,
|
||||
type MemoryWatchPressureWarningState,
|
||||
@@ -324,6 +334,19 @@ type ManagedCollection = {
|
||||
kind: "memory" | "custom" | "sessions";
|
||||
};
|
||||
|
||||
type QmdCollectionValidationDebug = NonNullable<
|
||||
NonNullable<MemorySearchRuntimeDebug["qmd"]>["collectionValidation"]
|
||||
>;
|
||||
type QmdMultiCollectionProbeDebug = NonNullable<
|
||||
NonNullable<MemorySearchRuntimeDebug["qmd"]>["multiCollectionProbe"]
|
||||
>;
|
||||
type QmdSearchPlanDebug = NonNullable<NonNullable<MemorySearchRuntimeDebug["qmd"]>["searchPlan"]>;
|
||||
type QmdSearchRuntimeDebugContext = {
|
||||
collectionValidation?: QmdCollectionValidationDebug;
|
||||
multiCollectionProbe?: QmdMultiCollectionProbeDebug;
|
||||
searchPlan?: QmdSearchPlanDebug;
|
||||
};
|
||||
|
||||
type QmdManagerMode = "full" | "status" | "cli";
|
||||
type QmdManagerRuntimeConfig = {
|
||||
workspaceDir: string;
|
||||
@@ -441,6 +464,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
private mode: QmdManagerMode = "full";
|
||||
private readonly closeSignal: Promise<void>;
|
||||
private resolveCloseSignal!: () => void;
|
||||
private qmdRuntimeIdentityPromise: Promise<string> | null = null;
|
||||
private db: SqliteDatabase | null = null;
|
||||
private lastUpdateAt: number | null = null;
|
||||
private lastEmbedAt: number | null = null;
|
||||
@@ -453,6 +477,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
private readonly sessionWarm = new Set<string>();
|
||||
private collectionPatternFlag: QmdCollectionPatternFlag | null = "--mask";
|
||||
private multiCollectionFilterSupported: boolean | null = null;
|
||||
private pendingCollectionValidationDebug: QmdCollectionValidationDebug | undefined;
|
||||
|
||||
private constructor(params: {
|
||||
agentId: string;
|
||||
@@ -612,11 +637,150 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCollections(): Promise<void> {
|
||||
private qmdRuntimeCacheSources(): string[] {
|
||||
return [...this.sources].toSorted();
|
||||
}
|
||||
|
||||
private qmdRuntimeCacheCollections(): QmdRuntimeManagedCollection[] {
|
||||
return this.qmd.collections.map((collection) => ({
|
||||
name: collection.name,
|
||||
kind: collection.kind,
|
||||
path: collection.path,
|
||||
pattern: collection.pattern,
|
||||
}));
|
||||
}
|
||||
|
||||
private async buildQmdCollectionValidationCacheContext(): Promise<QmdRuntimeCollectionValidationCacheContext> {
|
||||
return {
|
||||
workspaceDir: this.workspaceDir,
|
||||
agentId: this.agentId,
|
||||
qmdCommand: this.qmd.command,
|
||||
qmdVersion: await this.resolveQmdRuntimeIdentity(),
|
||||
qmdIndexPath: this.indexPath,
|
||||
searchMode: this.qmd.searchMode,
|
||||
collections: this.qmdRuntimeCacheCollections(),
|
||||
sources: this.qmdRuntimeCacheSources(),
|
||||
};
|
||||
}
|
||||
|
||||
private async buildQmdMultiCollectionProbeCacheContext(): Promise<QmdRuntimeMultiCollectionProbeCacheContext> {
|
||||
return {
|
||||
workspaceDir: this.workspaceDir,
|
||||
agentId: this.agentId,
|
||||
qmdCommand: this.qmd.command,
|
||||
qmdVersion: await this.resolveQmdRuntimeIdentity(),
|
||||
qmdIndexPath: this.indexPath,
|
||||
searchMode: this.qmd.searchMode,
|
||||
sources: this.qmdRuntimeCacheSources(),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveQmdRuntimeIdentity(): Promise<string> {
|
||||
this.qmdRuntimeIdentityPromise ??= this.readQmdRuntimeIdentity();
|
||||
return this.qmdRuntimeIdentityPromise;
|
||||
}
|
||||
|
||||
private async readQmdRuntimeIdentity(): Promise<string> {
|
||||
const commandIdentity = `command:${this.qmd.command}`;
|
||||
try {
|
||||
const result = await this.runQmd(["--version"], {
|
||||
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 2_000),
|
||||
});
|
||||
const versionText = `${result.stdout}\n${result.stderr}`.trim();
|
||||
return versionText ? `${commandIdentity};version:${versionText}` : commandIdentity;
|
||||
} catch {
|
||||
return commandIdentity;
|
||||
}
|
||||
}
|
||||
|
||||
private recordSearchPlanDebug(params: {
|
||||
debugContext: QmdSearchRuntimeDebugContext;
|
||||
command: "query" | "search" | "vsearch";
|
||||
collectionNames: string[];
|
||||
collectionGroups: string[][];
|
||||
}): void {
|
||||
const sources = uniqueValues(
|
||||
params.collectionNames
|
||||
.map((collectionName) => this.collectionRoots.get(collectionName)?.kind)
|
||||
.filter((source): source is MemorySource => Boolean(source)),
|
||||
);
|
||||
params.debugContext.searchPlan = {
|
||||
command: params.command,
|
||||
collectionCount: params.collectionNames.length,
|
||||
groupCount: params.collectionGroups.length,
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
private beginQmdSearchRuntimeDebug(): QmdSearchRuntimeDebugContext {
|
||||
const debugContext: QmdSearchRuntimeDebugContext = {};
|
||||
if (this.pendingCollectionValidationDebug) {
|
||||
debugContext.collectionValidation = this.pendingCollectionValidationDebug;
|
||||
this.pendingCollectionValidationDebug = undefined;
|
||||
}
|
||||
return debugContext;
|
||||
}
|
||||
|
||||
private consumeQmdRuntimeDebug(
|
||||
debugContext: QmdSearchRuntimeDebugContext,
|
||||
): MemorySearchRuntimeDebug["qmd"] | undefined {
|
||||
const debug: NonNullable<MemorySearchRuntimeDebug["qmd"]> = {};
|
||||
if (debugContext.collectionValidation) {
|
||||
debug.collectionValidation = debugContext.collectionValidation;
|
||||
}
|
||||
if (debugContext.multiCollectionProbe) {
|
||||
debug.multiCollectionProbe = debugContext.multiCollectionProbe;
|
||||
}
|
||||
if (debugContext.searchPlan) {
|
||||
debug.searchPlan = debugContext.searchPlan;
|
||||
}
|
||||
return Object.keys(debug).length > 0 ? debug : undefined;
|
||||
}
|
||||
|
||||
private async ensureCollectionPathsBestEffort(): Promise<void> {
|
||||
for (const collection of this.qmd.collections) {
|
||||
try {
|
||||
await this.ensureCollectionPath(collection);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`qmd collection path prepare failed for ${collection.name}: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCollections(options?: {
|
||||
force?: boolean;
|
||||
debugContext?: QmdSearchRuntimeDebugContext;
|
||||
}): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const cacheContext = await this.buildQmdCollectionValidationCacheContext();
|
||||
if (!options?.force) {
|
||||
const cached = await readQmdCollectionValidationCache(cacheContext);
|
||||
if (cached.state === "hit") {
|
||||
await this.ensureCollectionPathsBestEffort();
|
||||
const debug: QmdCollectionValidationDebug = {
|
||||
cacheState: "hit",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
collectionCount: cached.value.validation.collectionCount,
|
||||
listCalls: 0,
|
||||
showCalls: 0,
|
||||
};
|
||||
if (options?.debugContext) {
|
||||
options.debugContext.collectionValidation = debug;
|
||||
} else {
|
||||
this.pendingCollectionValidationDebug = debug;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = { listCalls: 0, showCalls: 0 };
|
||||
let validationComplete = true;
|
||||
// QMD collections are persisted inside the index database and must be created
|
||||
// via the CLI. Prefer listing existing collections when supported, otherwise
|
||||
// fall back to best-effort idempotent `qmd collection add`.
|
||||
const existing = await this.listCollectionsBestEffort();
|
||||
const existing = await this.listCollectionsBestEffort(stats);
|
||||
|
||||
await this.migrateLegacyUnscopedCollections(existing);
|
||||
|
||||
@@ -631,6 +795,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
if (!this.isCollectionMissingError(message)) {
|
||||
validationComplete = false;
|
||||
log.warn(`qmd collection remove failed for ${collection.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -661,13 +826,36 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
pattern: collection.pattern,
|
||||
});
|
||||
} else {
|
||||
validationComplete = false;
|
||||
log.warn(`qmd collection add skipped for ${collection.name}: ${message}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
validationComplete = false;
|
||||
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
const wroteCache = validationComplete
|
||||
? await writeQmdCollectionValidationCache(cacheContext)
|
||||
: false;
|
||||
const debug: QmdCollectionValidationDebug = {
|
||||
cacheState: validationComplete
|
||||
? options?.force
|
||||
? "bypass-force"
|
||||
: wroteCache
|
||||
? "write"
|
||||
: "error"
|
||||
: "error",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
collectionCount: this.qmd.collections.length,
|
||||
listCalls: stats.listCalls,
|
||||
showCalls: stats.showCalls,
|
||||
};
|
||||
if (options?.debugContext) {
|
||||
options.debugContext.collectionValidation = debug;
|
||||
} else {
|
||||
this.pendingCollectionValidationDebug = debug;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryRebindSameNameCollection(params: {
|
||||
@@ -713,9 +901,15 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
);
|
||||
}
|
||||
|
||||
private async listCollectionsBestEffort(): Promise<Map<string, ListedCollection>> {
|
||||
private async listCollectionsBestEffort(stats?: {
|
||||
listCalls: number;
|
||||
showCalls: number;
|
||||
}): Promise<Map<string, ListedCollection>> {
|
||||
const existing = new Map<string, ListedCollection>();
|
||||
try {
|
||||
if (stats) {
|
||||
stats.listCalls += 1;
|
||||
}
|
||||
const result = await this.runQmd(["collection", "list", "--json"], {
|
||||
timeoutMs: this.qmd.update.commandTimeoutMs,
|
||||
});
|
||||
@@ -737,6 +931,9 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (stats) {
|
||||
stats.showCalls += 1;
|
||||
}
|
||||
const showResult = await this.runQmd(["collection", "show", collection.name], {
|
||||
timeoutMs: this.qmd.update.commandTimeoutMs,
|
||||
});
|
||||
@@ -956,14 +1153,17 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
);
|
||||
}
|
||||
|
||||
private async tryRepairMissingCollectionSearch(err: unknown): Promise<boolean> {
|
||||
private async tryRepairMissingCollectionSearch(
|
||||
err: unknown,
|
||||
debugContext: QmdSearchRuntimeDebugContext,
|
||||
): Promise<boolean> {
|
||||
if (!this.isMissingCollectionSearchError(err)) {
|
||||
return false;
|
||||
}
|
||||
log.warn(
|
||||
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
|
||||
);
|
||||
await this.ensureCollections();
|
||||
await this.ensureCollections({ force: true, debugContext });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1318,6 +1518,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (searchSignal?.aborted) {
|
||||
throw asAbortError(searchSignal);
|
||||
}
|
||||
const debugContext = this.beginQmdSearchRuntimeDebug();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
@@ -1344,6 +1545,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const runSearchAttempt = async (
|
||||
allowMissingCollectionRepair: boolean,
|
||||
): Promise<QmdQueryResult[]> => {
|
||||
let attemptedCombinedCollectionFilter = false;
|
||||
try {
|
||||
if (mcporterEnabled) {
|
||||
const minScore = opts?.minScore ?? 0;
|
||||
@@ -1402,7 +1604,15 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const collectionGroups = await this.resolveCollectionSearchGroups(
|
||||
collectionNames,
|
||||
searchSignal,
|
||||
debugContext,
|
||||
);
|
||||
this.recordSearchPlanDebug({
|
||||
debugContext,
|
||||
command: qmdSearchCommand,
|
||||
collectionNames,
|
||||
collectionGroups,
|
||||
});
|
||||
attemptedCombinedCollectionFilter = collectionGroups.some((group) => group.length > 1);
|
||||
if (collectionGroups.length > 1) {
|
||||
return await this.runQueryAcrossCollectionGroups(
|
||||
trimmed,
|
||||
@@ -1424,6 +1634,9 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
qmdSearchCommand !== "query" &&
|
||||
this.isUnsupportedQmdOptionError(err)
|
||||
) {
|
||||
if (attemptedCombinedCollectionFilter) {
|
||||
await this.markQmdMultiCollectionFiltersUnsupported(debugContext);
|
||||
}
|
||||
effectiveSearchMode = "query";
|
||||
searchFallbackReason = "unsupported-search-flags";
|
||||
log.warn(
|
||||
@@ -1433,7 +1646,14 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const collectionGroups = await this.resolveCollectionSearchGroups(
|
||||
collectionNames,
|
||||
searchSignal,
|
||||
debugContext,
|
||||
);
|
||||
this.recordSearchPlanDebug({
|
||||
debugContext,
|
||||
command: "query",
|
||||
collectionNames,
|
||||
collectionGroups,
|
||||
});
|
||||
if (collectionGroups.length > 1) {
|
||||
return await this.runQueryAcrossCollectionGroups(
|
||||
trimmed,
|
||||
@@ -1463,7 +1683,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
try {
|
||||
parsed = await runSearchAttempt(true);
|
||||
} catch (err) {
|
||||
if (!(await this.tryRepairMissingCollectionSearch(err))) {
|
||||
if (!(await this.tryRepairMissingCollectionSearch(err, debugContext))) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
parsed = await runSearchAttempt(false);
|
||||
@@ -1512,6 +1732,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
configuredMode: qmdSearchCommand,
|
||||
effectiveMode: effectiveSearchMode,
|
||||
fallback: searchFallbackReason,
|
||||
qmd: this.consumeQmdRuntimeDebug(debugContext),
|
||||
});
|
||||
let ranked = results;
|
||||
if (opts?.sources?.length) {
|
||||
@@ -3370,23 +3591,41 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
private async resolveCollectionSearchGroups(
|
||||
collectionNames: string[],
|
||||
signal?: AbortSignal,
|
||||
debugContext?: QmdSearchRuntimeDebugContext,
|
||||
): Promise<string[][]> {
|
||||
if (collectionNames.length <= 1) {
|
||||
return [collectionNames];
|
||||
}
|
||||
if (!(await this.supportsQmdMultiCollectionFilters(signal))) {
|
||||
if (!(await this.supportsQmdMultiCollectionFilters(signal, debugContext))) {
|
||||
return collectionNames.map((collectionName) => [collectionName]);
|
||||
}
|
||||
return this.groupCollectionNamesBySource(collectionNames);
|
||||
}
|
||||
|
||||
private async supportsQmdMultiCollectionFilters(signal?: AbortSignal): Promise<boolean> {
|
||||
private async supportsQmdMultiCollectionFilters(
|
||||
signal?: AbortSignal,
|
||||
debugContext?: QmdSearchRuntimeDebugContext,
|
||||
): Promise<boolean> {
|
||||
if (signal?.aborted) {
|
||||
throw asAbortError(signal);
|
||||
}
|
||||
if (this.multiCollectionFilterSupported !== null) {
|
||||
return this.multiCollectionFilterSupported;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext();
|
||||
const cached = await readQmdMultiCollectionProbeCache(cacheContext);
|
||||
if (cached.state === "hit") {
|
||||
this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported;
|
||||
if (debugContext) {
|
||||
debugContext.multiCollectionProbe = {
|
||||
cacheState: "hit",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
supported: this.multiCollectionFilterSupported,
|
||||
};
|
||||
}
|
||||
return this.multiCollectionFilterSupported;
|
||||
}
|
||||
try {
|
||||
const result = await this.runQmd(["--help"], {
|
||||
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
|
||||
@@ -3395,17 +3634,50 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
const helpText = `${result.stdout}\n${result.stderr}`;
|
||||
this.multiCollectionFilterSupported =
|
||||
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
|
||||
const wroteCache = await writeQmdMultiCollectionProbeCache(
|
||||
cacheContext,
|
||||
this.multiCollectionFilterSupported,
|
||||
);
|
||||
if (debugContext) {
|
||||
debugContext.multiCollectionProbe = {
|
||||
cacheState: wroteCache ? "write" : "error",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
supported: this.multiCollectionFilterSupported,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Cancellation says nothing about QMD capabilities; leave the probe uncached.
|
||||
if (signal?.aborted) {
|
||||
throw asAbortError(signal);
|
||||
}
|
||||
this.multiCollectionFilterSupported = false;
|
||||
if (debugContext) {
|
||||
debugContext.multiCollectionProbe = {
|
||||
cacheState: "error",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
supported: false,
|
||||
};
|
||||
}
|
||||
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
|
||||
}
|
||||
return this.multiCollectionFilterSupported;
|
||||
}
|
||||
|
||||
private async markQmdMultiCollectionFiltersUnsupported(
|
||||
debugContext: QmdSearchRuntimeDebugContext,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const cacheContext = await this.buildQmdMultiCollectionProbeCacheContext();
|
||||
this.multiCollectionFilterSupported = false;
|
||||
await clearQmdMultiCollectionProbeCache(cacheContext);
|
||||
const wroteCache = await writeQmdMultiCollectionProbeCache(cacheContext, false);
|
||||
debugContext.multiCollectionProbe = {
|
||||
cacheState: wroteCache ? "write" : "error",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
supported: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async runQueryAcrossCollectionGroups(
|
||||
query: string,
|
||||
limit: number,
|
||||
|
||||
289
extensions/memory-core/src/memory/qmd-runtime-cache.test.ts
Normal file
289
extensions/memory-core/src/memory/qmd-runtime-cache.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
configureMemoryCoreDreamingState,
|
||||
configureMemoryCoreDreamingStateForTests,
|
||||
openMemoryCoreStateStore,
|
||||
memoryCoreWorkspaceEntryKey,
|
||||
resetMemoryCoreDreamingStateForTests,
|
||||
} from "../dreaming-state.js";
|
||||
import {
|
||||
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
|
||||
QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS,
|
||||
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS,
|
||||
buildQmdMultiCollectionProbeCacheContextHash,
|
||||
clearQmdCollectionValidationCache,
|
||||
clearQmdMultiCollectionProbeCache,
|
||||
readQmdCollectionValidationCache,
|
||||
readQmdMultiCollectionProbeCache,
|
||||
type QmdRuntimeCollectionValidationCacheContext,
|
||||
type QmdRuntimeManagedCollection,
|
||||
type QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
writeQmdCollectionValidationCache,
|
||||
writeQmdMultiCollectionProbeCache,
|
||||
} from "./qmd-runtime-cache.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
while (tempRoots.length > 0) {
|
||||
const root = tempRoots.pop();
|
||||
if (root) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
resetMemoryCoreDreamingStateForTests();
|
||||
});
|
||||
|
||||
async function clearStore(namespace: string): Promise<void> {
|
||||
try {
|
||||
await openMemoryCoreStateStore({
|
||||
namespace,
|
||||
maxEntries: 1_000,
|
||||
}).clear();
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await clearStore(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE);
|
||||
await clearStore(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE);
|
||||
});
|
||||
|
||||
function makeWorkspace(): Promise<string> {
|
||||
const prefix = path.join(os.tmpdir(), `qmd-runtime-cache-${Date.now()}-`);
|
||||
return fs.mkdtemp(prefix).then((workspaceDir) => {
|
||||
tempRoots.push(workspaceDir);
|
||||
return workspaceDir;
|
||||
});
|
||||
}
|
||||
|
||||
function managedCollections(): QmdRuntimeManagedCollection[] {
|
||||
return [
|
||||
{
|
||||
name: "project-notes",
|
||||
kind: "memory",
|
||||
path: "/repo/project-notes",
|
||||
pattern: "*.md",
|
||||
},
|
||||
{
|
||||
name: "sessions",
|
||||
kind: "sessions",
|
||||
path: "/repo/sessions",
|
||||
pattern: "*",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function collectionValidationContext(
|
||||
workspaceDir: string,
|
||||
): QmdRuntimeCollectionValidationCacheContext {
|
||||
return {
|
||||
workspaceDir,
|
||||
agentId: "agent-a",
|
||||
qmdCommand: "qmd",
|
||||
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
|
||||
searchMode: "search",
|
||||
collections: managedCollections(),
|
||||
sources: ["memory", "sessions"],
|
||||
};
|
||||
}
|
||||
|
||||
function multiCollectionProbeContext(
|
||||
workspaceDir: string,
|
||||
): QmdRuntimeMultiCollectionProbeCacheContext {
|
||||
return {
|
||||
workspaceDir,
|
||||
agentId: "agent-a",
|
||||
qmdCommand: "qmd",
|
||||
qmdIndexPath: path.join(workspaceDir, ".openclaw", "index.sqlite"),
|
||||
searchMode: "search",
|
||||
sources: ["memory", "sessions"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("qmd-runtime-cache", () => {
|
||||
it("writes and reads collection validation cache entries", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const context = collectionValidationContext(workspaceDir);
|
||||
const writeStartedAtMs = 1_000;
|
||||
|
||||
const writeOk = await writeQmdCollectionValidationCache(context, writeStartedAtMs);
|
||||
expect(writeOk).toBe(true);
|
||||
|
||||
const read = await readQmdCollectionValidationCache(
|
||||
{ ...context, sources: ["sessions", "memory"] },
|
||||
writeStartedAtMs + 1,
|
||||
);
|
||||
expect(read).toMatchObject({
|
||||
state: "hit",
|
||||
value: {
|
||||
validation: {
|
||||
ok: true,
|
||||
collectionCount: context.collections.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("writes and reads multi-collection probe cache entries", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const context = multiCollectionProbeContext(workspaceDir);
|
||||
const writeStartedAtMs = 2_000;
|
||||
|
||||
const writeOk = await writeQmdMultiCollectionProbeCache(context, true, writeStartedAtMs);
|
||||
expect(writeOk).toBe(true);
|
||||
|
||||
const read = await readQmdMultiCollectionProbeCache(context, writeStartedAtMs + 1);
|
||||
expect(read).toMatchObject({
|
||||
state: "hit",
|
||||
value: {
|
||||
multiCollectionProbe: {
|
||||
supported: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes cache entries by workspace", async () => {
|
||||
const firstWorkspace = await makeWorkspace();
|
||||
const secondWorkspace = await makeWorkspace();
|
||||
const context = collectionValidationContext(firstWorkspace);
|
||||
|
||||
expect(await writeQmdCollectionValidationCache(context, 3_000)).toBe(true);
|
||||
|
||||
const sameLogicalDifferentWorkspace: QmdRuntimeCollectionValidationCacheContext = {
|
||||
...context,
|
||||
workspaceDir: secondWorkspace,
|
||||
qmdIndexPath: path.join(secondWorkspace, ".openclaw", "index.sqlite"),
|
||||
};
|
||||
|
||||
const miss = await readQmdCollectionValidationCache(sameLogicalDifferentWorkspace, 3_001);
|
||||
expect(miss).toStrictEqual({ state: "miss" });
|
||||
});
|
||||
|
||||
it("misses collection validation cache when managed collection paths change", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const context = collectionValidationContext(workspaceDir);
|
||||
|
||||
expect(await writeQmdCollectionValidationCache(context, 3_500)).toBe(true);
|
||||
|
||||
const changedContext: QmdRuntimeCollectionValidationCacheContext = {
|
||||
...context,
|
||||
collections: context.collections.map((collection) =>
|
||||
collection.name === "project-notes"
|
||||
? { ...collection, path: `${collection.path}-moved` }
|
||||
: collection,
|
||||
),
|
||||
};
|
||||
|
||||
expect(await readQmdCollectionValidationCache(changedContext, 3_501)).toStrictEqual({
|
||||
state: "miss",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats cache misses for malformed values and expired entries", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const context = multiCollectionProbeContext(workspaceDir);
|
||||
const nowMs = 4_000;
|
||||
await writeQmdMultiCollectionProbeCache(context, false, nowMs);
|
||||
|
||||
const key = memoryCoreWorkspaceEntryKey(
|
||||
workspaceDir,
|
||||
`qmd-runtime-cache.multi-collection-probe:${buildQmdMultiCollectionProbeCacheContextHash(context)}`,
|
||||
);
|
||||
const store = openMemoryCoreStateStore({
|
||||
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
maxEntries: 1_000,
|
||||
});
|
||||
|
||||
await store.register(key, {
|
||||
version: 1,
|
||||
createdAtMs: "bad",
|
||||
expiresAtMs: 0,
|
||||
keyHash: "bad",
|
||||
multiCollectionProbe: { supported: true },
|
||||
});
|
||||
|
||||
const malformed = await readQmdMultiCollectionProbeCache(context, nowMs + 1);
|
||||
expect(malformed).toStrictEqual({ state: "miss" });
|
||||
|
||||
const expired = await readQmdMultiCollectionProbeCache(
|
||||
context,
|
||||
nowMs + QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS + 1,
|
||||
);
|
||||
expect(expired).toStrictEqual({ state: "miss" });
|
||||
});
|
||||
|
||||
it("uses separate namespaces for validation and probe entries", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const validationContext = collectionValidationContext(workspaceDir);
|
||||
const probeContext = multiCollectionProbeContext(workspaceDir);
|
||||
|
||||
expect(await writeQmdCollectionValidationCache(validationContext, 5_000)).toBe(true);
|
||||
expect(await writeQmdMultiCollectionProbeCache(probeContext, true, 5_000)).toBe(true);
|
||||
|
||||
const validationStore = openMemoryCoreStateStore({
|
||||
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
|
||||
maxEntries: 1_000,
|
||||
});
|
||||
const probeStore = openMemoryCoreStateStore({
|
||||
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
maxEntries: 1_000,
|
||||
});
|
||||
|
||||
expect((await validationStore.entries()).length).toBeGreaterThan(0);
|
||||
expect((await probeStore.entries()).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("fails open when state store is unavailable", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const validationContext = collectionValidationContext(workspaceDir);
|
||||
const probeContext = multiCollectionProbeContext(workspaceDir);
|
||||
|
||||
configureMemoryCoreDreamingState(() => {
|
||||
throw new Error("state store unavailable");
|
||||
});
|
||||
|
||||
try {
|
||||
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
|
||||
state: "miss",
|
||||
});
|
||||
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(false);
|
||||
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
|
||||
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(false);
|
||||
} finally {
|
||||
await configureMemoryCoreDreamingStateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes bounded TTL windows", () => {
|
||||
expect(QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS).toBe(5 * 60_000);
|
||||
expect(QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS).toBe(10 * 60_000);
|
||||
});
|
||||
|
||||
it("can clear cache keys explicitly", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const validationContext = collectionValidationContext(workspaceDir);
|
||||
const probeContext = multiCollectionProbeContext(workspaceDir);
|
||||
|
||||
expect(await writeQmdCollectionValidationCache(validationContext)).toBe(true);
|
||||
expect(await writeQmdMultiCollectionProbeCache(probeContext, true)).toBe(true);
|
||||
|
||||
await clearQmdCollectionValidationCache(validationContext);
|
||||
await clearQmdMultiCollectionProbeCache(probeContext);
|
||||
|
||||
expect(await readQmdCollectionValidationCache(validationContext)).toStrictEqual({
|
||||
state: "miss",
|
||||
});
|
||||
expect(await readQmdMultiCollectionProbeCache(probeContext)).toStrictEqual({ state: "miss" });
|
||||
});
|
||||
});
|
||||
432
extensions/memory-core/src/memory/qmd-runtime-cache.ts
Normal file
432
extensions/memory-core/src/memory/qmd-runtime-cache.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
// Memory Core QMD runtime cache helpers.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { memoryCoreWorkspaceEntryKey, openMemoryCoreStateStore } from "../dreaming-state.js";
|
||||
|
||||
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE =
|
||||
"qmd-runtime-cache.collection-validation";
|
||||
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE =
|
||||
"qmd-runtime-cache.multi-collection-probe";
|
||||
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES = 1_000;
|
||||
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES = 1_000;
|
||||
export const QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS = 5 * 60_000;
|
||||
export const QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS = 10 * 60_000;
|
||||
|
||||
const QMD_RUNTIME_CACHE_ENTRY_VERSION = 1;
|
||||
|
||||
export type QmdRuntimeManagedCollection = {
|
||||
name: string;
|
||||
kind: "memory" | "custom" | "sessions";
|
||||
path: string;
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
type QmdRuntimeCacheContextBase = {
|
||||
workspaceDir: string;
|
||||
agentId: string;
|
||||
qmdCommand: string;
|
||||
qmdVersion?: string;
|
||||
qmdIndexPath: string;
|
||||
searchMode: string;
|
||||
};
|
||||
|
||||
export type QmdRuntimeCollectionValidationCacheContext = QmdRuntimeCacheContextBase & {
|
||||
collections: readonly QmdRuntimeManagedCollection[];
|
||||
sources: readonly string[];
|
||||
};
|
||||
|
||||
export type QmdRuntimeMultiCollectionProbeCacheContext = QmdRuntimeCacheContextBase & {
|
||||
sources: readonly string[];
|
||||
};
|
||||
|
||||
export type QmdRuntimeCacheCollectionValidationEntry = {
|
||||
version: 1;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
keyHash: string;
|
||||
validation: {
|
||||
ok: true;
|
||||
collectionConfigHash: string;
|
||||
collectionCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type QmdRuntimeCacheMultiCollectionProbeEntry = {
|
||||
version: 1;
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
keyHash: string;
|
||||
multiCollectionProbe: {
|
||||
supported: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type QmdRuntimeCacheResult<T> =
|
||||
| {
|
||||
state: "hit";
|
||||
value: T;
|
||||
}
|
||||
| { state: "miss" };
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeCollection(collection: QmdRuntimeManagedCollection) {
|
||||
return {
|
||||
name: normalizeText(collection.name),
|
||||
kind: collection.kind,
|
||||
pathHash: normalizePathIdentity(collection.path),
|
||||
pattern: normalizeText(collection.pattern),
|
||||
};
|
||||
}
|
||||
|
||||
function hashText(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function normalizePathIdentity(value: string): string {
|
||||
const normalized =
|
||||
process.platform === "win32" ? normalizeText(value).toLowerCase() : normalizeText(value);
|
||||
return hashText(normalized);
|
||||
}
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map((value) => normalizeText(value)).filter(Boolean))].toSorted();
|
||||
}
|
||||
|
||||
function buildCollectionConfigHash(collections: readonly QmdRuntimeManagedCollection[]): string {
|
||||
const normalized = collections
|
||||
.map((collection) => ({
|
||||
...normalizeCollection(collection),
|
||||
}))
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.pathHash.localeCompare(right.pathHash) ||
|
||||
left.pattern.localeCompare(right.pattern),
|
||||
)
|
||||
.map((entry) => `${entry.name}|${entry.kind}|${entry.pathHash}|${entry.pattern}`)
|
||||
.join(";");
|
||||
return hashText(normalized);
|
||||
}
|
||||
|
||||
function buildCollectionValidationCacheContextInput(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
agentId: normalizeText(params.agentId),
|
||||
commandHash: hashText(normalizeText(params.qmdCommand)),
|
||||
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
|
||||
qmdVersion: normalizeText(params.qmdVersion ?? ""),
|
||||
searchMode: params.searchMode,
|
||||
sourceSet: sortedUnique(params.sources),
|
||||
collectionConfigHash: buildCollectionConfigHash(params.collections),
|
||||
});
|
||||
}
|
||||
|
||||
function buildMultiCollectionProbeCacheContextInput(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
agentId: normalizeText(params.agentId),
|
||||
commandHash: hashText(normalizeText(params.qmdCommand)),
|
||||
indexPathHash: normalizePathIdentity(params.qmdIndexPath),
|
||||
qmdVersion: normalizeText(params.qmdVersion ?? ""),
|
||||
searchMode: params.searchMode,
|
||||
sourceSet: sortedUnique(params.sources),
|
||||
});
|
||||
}
|
||||
|
||||
function buildCollectionValidationCacheHash(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
): string {
|
||||
return hashText(buildCollectionValidationCacheContextInput(params));
|
||||
}
|
||||
|
||||
function buildMultiCollectionProbeCacheHash(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): string {
|
||||
return hashText(buildMultiCollectionProbeCacheContextInput(params));
|
||||
}
|
||||
|
||||
export function buildQmdCollectionValidationCacheContextHash(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
): string {
|
||||
return buildCollectionValidationCacheHash(params);
|
||||
}
|
||||
|
||||
export function buildQmdMultiCollectionProbeCacheContextHash(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): string {
|
||||
return buildMultiCollectionProbeCacheHash(params);
|
||||
}
|
||||
|
||||
function collectionValidationStore(): PluginStateKeyedStore<QmdRuntimeCacheCollectionValidationEntry> {
|
||||
return openMemoryCoreStateStore<QmdRuntimeCacheCollectionValidationEntry>({
|
||||
namespace: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_NAMESPACE,
|
||||
maxEntries: QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
function multiCollectionProbeStore(): PluginStateKeyedStore<QmdRuntimeCacheMultiCollectionProbeEntry> {
|
||||
return openMemoryCoreStateStore<QmdRuntimeCacheMultiCollectionProbeEntry>({
|
||||
namespace: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_NAMESPACE,
|
||||
maxEntries: QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_MAX_ENTRIES,
|
||||
});
|
||||
}
|
||||
|
||||
function collectionValidationEntryKey(params: QmdRuntimeCollectionValidationCacheContext): string {
|
||||
return memoryCoreWorkspaceEntryKey(
|
||||
params.workspaceDir,
|
||||
`qmd-runtime-cache.collection-validation:${buildCollectionValidationCacheHash(params)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function multiCollectionProbeEntryKey(params: QmdRuntimeMultiCollectionProbeCacheContext): string {
|
||||
return memoryCoreWorkspaceEntryKey(
|
||||
params.workspaceDir,
|
||||
`qmd-runtime-cache.multi-collection-probe:${buildMultiCollectionProbeCacheHash(params)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCollectionValidationEntry(
|
||||
value: unknown,
|
||||
nowMs: number,
|
||||
expectedKeyHash: string,
|
||||
): QmdRuntimeCacheCollectionValidationEntry | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const createdAtMs =
|
||||
typeof record.createdAtMs === "number"
|
||||
? Math.max(0, Math.floor(record.createdAtMs))
|
||||
: Number.NaN;
|
||||
const expiresAtMs =
|
||||
typeof record.expiresAtMs === "number"
|
||||
? Math.max(0, Math.floor(record.expiresAtMs))
|
||||
: Number.NaN;
|
||||
if (
|
||||
!Number.isFinite(createdAtMs) ||
|
||||
!Number.isFinite(expiresAtMs) ||
|
||||
!Number.isFinite(nowMs) ||
|
||||
nowMs >= expiresAtMs
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
|
||||
if (keyHash !== expectedKeyHash) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validation = record.validation as unknown;
|
||||
if (typeof validation !== "object" || validation === null) {
|
||||
return undefined;
|
||||
}
|
||||
const validationRecord = validation as Record<string, unknown>;
|
||||
if (validationRecord.ok !== true) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof validationRecord.collectionConfigHash !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof validationRecord.collectionCount !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
keyHash,
|
||||
validation: {
|
||||
ok: true,
|
||||
collectionConfigHash: normalizeText(validationRecord.collectionConfigHash),
|
||||
collectionCount: Math.max(0, Math.floor(validationRecord.collectionCount)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMultiCollectionProbeEntry(
|
||||
value: unknown,
|
||||
nowMs: number,
|
||||
expectedKeyHash: string,
|
||||
): QmdRuntimeCacheMultiCollectionProbeEntry | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== QMD_RUNTIME_CACHE_ENTRY_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const createdAtMs =
|
||||
typeof record.createdAtMs === "number"
|
||||
? Math.max(0, Math.floor(record.createdAtMs))
|
||||
: Number.NaN;
|
||||
const expiresAtMs =
|
||||
typeof record.expiresAtMs === "number"
|
||||
? Math.max(0, Math.floor(record.expiresAtMs))
|
||||
: Number.NaN;
|
||||
if (
|
||||
!Number.isFinite(createdAtMs) ||
|
||||
!Number.isFinite(expiresAtMs) ||
|
||||
!Number.isFinite(nowMs) ||
|
||||
nowMs >= expiresAtMs
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyHash = normalizeText(typeof record.keyHash === "string" ? record.keyHash : "");
|
||||
if (keyHash !== expectedKeyHash) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const probe = record.multiCollectionProbe as unknown;
|
||||
if (typeof probe !== "object" || probe === null) {
|
||||
return undefined;
|
||||
}
|
||||
const probeRecord = probe as Record<string, unknown>;
|
||||
if (typeof probeRecord.supported !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
|
||||
createdAtMs,
|
||||
expiresAtMs,
|
||||
keyHash,
|
||||
multiCollectionProbe: {
|
||||
supported: probeRecord.supported,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheCollectionValidationEntry>> {
|
||||
try {
|
||||
const store = collectionValidationStore();
|
||||
const key = collectionValidationEntryKey(params);
|
||||
const expectedKeyHash = buildCollectionValidationCacheHash(params);
|
||||
const raw = await store.lookup(key);
|
||||
if (!raw) {
|
||||
return { state: "miss" };
|
||||
}
|
||||
const validated = normalizeCollectionValidationEntry(raw, nowMs, expectedKeyHash);
|
||||
return validated ? { state: "hit", value: validated } : { state: "miss" };
|
||||
} catch {
|
||||
return { state: "miss" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = collectionValidationEntryKey(params);
|
||||
const keyHash = buildCollectionValidationCacheHash(params);
|
||||
const collectionConfigHash = buildCollectionConfigHash(params.collections);
|
||||
const createdAtMs = Math.max(0, Math.floor(nowMs));
|
||||
const ttlMs = QMD_RUNTIME_CACHE_COLLECTION_VALIDATION_TTL_MS;
|
||||
const store = collectionValidationStore();
|
||||
await store.register(
|
||||
key,
|
||||
{
|
||||
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
|
||||
createdAtMs,
|
||||
expiresAtMs: createdAtMs + ttlMs,
|
||||
keyHash,
|
||||
validation: {
|
||||
ok: true,
|
||||
collectionConfigHash,
|
||||
collectionCount: params.collections.length,
|
||||
},
|
||||
},
|
||||
{ ttlMs },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearQmdCollectionValidationCache(
|
||||
params: QmdRuntimeCollectionValidationCacheContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const store = collectionValidationStore();
|
||||
await store.delete(collectionValidationEntryKey(params));
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
}
|
||||
|
||||
export async function readQmdMultiCollectionProbeCache(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
nowMs = Date.now(),
|
||||
): Promise<QmdRuntimeCacheResult<QmdRuntimeCacheMultiCollectionProbeEntry>> {
|
||||
try {
|
||||
const store = multiCollectionProbeStore();
|
||||
const key = multiCollectionProbeEntryKey(params);
|
||||
const expectedKeyHash = buildMultiCollectionProbeCacheHash(params);
|
||||
const raw = await store.lookup(key);
|
||||
if (!raw) {
|
||||
return { state: "miss" };
|
||||
}
|
||||
const validated = normalizeMultiCollectionProbeEntry(raw, nowMs, expectedKeyHash);
|
||||
return validated ? { state: "hit", value: validated } : { state: "miss" };
|
||||
} catch {
|
||||
return { state: "miss" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeQmdMultiCollectionProbeCache(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
supported: boolean,
|
||||
nowMs = Date.now(),
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = multiCollectionProbeEntryKey(params);
|
||||
const keyHash = buildMultiCollectionProbeCacheHash(params);
|
||||
const createdAtMs = Math.max(0, Math.floor(nowMs));
|
||||
const ttlMs = QMD_RUNTIME_CACHE_MULTI_COLLECTION_PROBE_TTL_MS;
|
||||
const store = multiCollectionProbeStore();
|
||||
await store.register(
|
||||
key,
|
||||
{
|
||||
version: QMD_RUNTIME_CACHE_ENTRY_VERSION,
|
||||
createdAtMs,
|
||||
expiresAtMs: createdAtMs + ttlMs,
|
||||
keyHash,
|
||||
multiCollectionProbe: {
|
||||
supported,
|
||||
},
|
||||
},
|
||||
{ ttlMs },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearQmdMultiCollectionProbeCache(
|
||||
params: QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const store = multiCollectionProbeStore();
|
||||
await store.delete(multiCollectionProbeEntryKey(params));
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,10 @@ describe("getMemorySearchManager caching", () => {
|
||||
|
||||
expect(first.manager).toBe(second.manager);
|
||||
expect(createQmdManagerMock.mock.calls).toHaveLength(1);
|
||||
expect(first.debug?.managerCacheState).toBe("cached-full-miss");
|
||||
expect(second.debug?.managerCacheState).toBe("cached-full-hit");
|
||||
expect(first.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(second.debug?.qmdIdentityHash).toBe(first.debug?.qmdIdentityHash);
|
||||
});
|
||||
|
||||
it("keeps the cached QMD manager active when the caller cancels a search", async () => {
|
||||
@@ -806,6 +810,10 @@ describe("getMemorySearchManager caching", () => {
|
||||
const fullManager = requireManager(full);
|
||||
const cliManager = requireManager(cli);
|
||||
|
||||
expect(cli.debug?.managerCacheState).toBe("transient-cli");
|
||||
expect(full.debug?.managerCacheState).toBe("cached-full-miss");
|
||||
expect(full.debug?.qmdIdentityHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(cli.debug?.qmdIdentityHash).toBe(full.debug?.qmdIdentityHash);
|
||||
expect(cliManager).toBe(cliPrimary);
|
||||
expect(cliManager).not.toBe(fullManager);
|
||||
const fullCreateParams = qmdCreateParams();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
// Memory Core plugin module implements search manager behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
@@ -48,6 +49,24 @@ type QmdManagerOpenFailure = {
|
||||
retryAfterMs: number;
|
||||
};
|
||||
|
||||
type MemorySearchManagerCacheState =
|
||||
| "cached-full-hit"
|
||||
| "cached-full-miss"
|
||||
| "transient-cli"
|
||||
| "transient-status"
|
||||
| "pending-create-wait"
|
||||
| "fallback-builtin"
|
||||
| "recent-failure-cooldown";
|
||||
|
||||
export type MemorySearchManagerDebug = {
|
||||
backend?: "builtin" | "qmd";
|
||||
purpose?: MemorySearchManagerPurpose;
|
||||
managerMs?: number;
|
||||
managerCacheState?: MemorySearchManagerCacheState;
|
||||
qmdIdentityHash?: string;
|
||||
failureCode?: "qmd-unavailable";
|
||||
};
|
||||
|
||||
type MemorySearchManagerCacheStore = {
|
||||
qmdManagerCache: Map<string, CachedQmdManagerEntry>;
|
||||
pendingQmdManagerCreates: Map<string, PendingQmdManagerCreate>;
|
||||
@@ -109,6 +128,7 @@ function loadQmdManagerModule() {
|
||||
export type MemorySearchManagerResult = {
|
||||
manager: Maybe<MemorySearchManager>;
|
||||
error?: string;
|
||||
debug?: MemorySearchManagerDebug;
|
||||
};
|
||||
|
||||
export type MemorySearchManagerPurpose = "default" | "status" | "cli";
|
||||
@@ -149,11 +169,42 @@ function clearQmdManagerOpenFailure(scopeKey: string, identityKey: string): void
|
||||
}
|
||||
}
|
||||
|
||||
function hashQmdManagerIdentity(identityKey: string): string {
|
||||
return createHash("sha256").update(identityKey).digest("hex");
|
||||
}
|
||||
|
||||
function applyManagerDebug(
|
||||
result: MemorySearchManagerResult,
|
||||
debug: MemorySearchManagerDebug,
|
||||
): MemorySearchManagerResult {
|
||||
if (result.debug && Object.keys(result.debug).length > 0 && Object.keys(debug).length === 0) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
debug: {
|
||||
...(result.debug ?? {}),
|
||||
...debug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMemorySearchManager(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
purpose?: MemorySearchManagerPurpose;
|
||||
}): Promise<MemorySearchManagerResult> {
|
||||
const acquireStartedAt = Date.now();
|
||||
const purpose = params.purpose ?? "default";
|
||||
const finish = (
|
||||
result: MemorySearchManagerResult,
|
||||
debug: MemorySearchManagerDebug,
|
||||
): MemorySearchManagerResult =>
|
||||
applyManagerDebug(result, {
|
||||
purpose,
|
||||
managerMs: Math.max(0, Date.now() - acquireStartedAt),
|
||||
...debug,
|
||||
});
|
||||
const resolved = resolveMemoryBackendConfig(params);
|
||||
if (resolved.backend === "qmd" && resolved.qmd) {
|
||||
const qmdResolved = resolved.qmd;
|
||||
@@ -163,6 +214,7 @@ export async function getMemorySearchManager(params: {
|
||||
const transient = params.purpose === "status" || params.purpose === "cli";
|
||||
const scopeKey = buildQmdManagerScopeKey(normalizedAgentId);
|
||||
const identityKey = buildQmdManagerIdentityKey(normalizedAgentId, qmdResolved, runtimeConfig);
|
||||
const debugIdentityHash = hashQmdManagerIdentity(identityKey);
|
||||
|
||||
const createPrimaryQmdManager = async (
|
||||
mode: "full" | "status" | "cli",
|
||||
@@ -254,10 +306,24 @@ export async function getMemorySearchManager(params: {
|
||||
// Status callers often close the manager they receive. Wrap the live
|
||||
// full manager with a no-op close so health/status probes do not tear
|
||||
// down the active QMD manager for the process.
|
||||
return { manager: new BorrowedMemoryManager(cached.manager) };
|
||||
return finish(
|
||||
{ manager: new BorrowedMemoryManager(cached.manager) },
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "cached-full-hit",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (params.purpose !== "cli") {
|
||||
return { manager: cached.manager };
|
||||
return finish(
|
||||
{ manager: cached.manager },
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "cached-full-hit",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,20 +332,44 @@ export async function getMemorySearchManager(params: {
|
||||
params.purpose === "cli" ? "cli" : "status",
|
||||
);
|
||||
return manager
|
||||
? { manager }
|
||||
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
|
||||
? finish(
|
||||
{ manager },
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: params.purpose === "cli" ? "transient-cli" : "transient-status",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
},
|
||||
)
|
||||
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason), {
|
||||
backend: "qmd",
|
||||
managerCacheState: "fallback-builtin",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
failureCode: "qmd-unavailable",
|
||||
});
|
||||
}
|
||||
|
||||
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
|
||||
if (recentFailure) {
|
||||
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
|
||||
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
|
||||
return finish(
|
||||
await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason),
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "recent-failure-cooldown",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
failureCode: "qmd-unavailable",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
|
||||
if (pending) {
|
||||
await pending.promise;
|
||||
return await getMemorySearchManager(params);
|
||||
return finish(await getMemorySearchManager(params), {
|
||||
backend: "qmd",
|
||||
managerCacheState: "pending-create-wait",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
});
|
||||
}
|
||||
|
||||
let pendingFailureReason: string | undefined;
|
||||
@@ -309,11 +399,25 @@ export async function getMemorySearchManager(params: {
|
||||
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
|
||||
const manager = await pendingCreate.promise;
|
||||
return manager
|
||||
? { manager }
|
||||
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
|
||||
? finish(
|
||||
{ manager },
|
||||
{
|
||||
backend: "qmd",
|
||||
managerCacheState: "cached-full-miss",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
},
|
||||
)
|
||||
: finish(await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason), {
|
||||
backend: "qmd",
|
||||
managerCacheState: "fallback-builtin",
|
||||
qmdIdentityHash: debugIdentityHash,
|
||||
failureCode: "qmd-unavailable",
|
||||
});
|
||||
}
|
||||
|
||||
return await getBuiltinMemorySearchManager(params);
|
||||
return finish(await getBuiltinMemorySearchManager(params), {
|
||||
backend: "builtin",
|
||||
});
|
||||
}
|
||||
|
||||
async function getBuiltinMemorySearchManagerAfterQmdFailure(
|
||||
|
||||
44
extensions/memory-core/src/runtime-provider.test.ts
Normal file
44
extensions/memory-core/src/runtime-provider.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Memory Core provider tests cover plugin runtime integration.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const managerDebug = {
|
||||
backend: "qmd" as const,
|
||||
purpose: "default" as const,
|
||||
managerMs: 7,
|
||||
managerCacheState: "cached-full-hit" as const,
|
||||
qmdIdentityHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
};
|
||||
|
||||
const getMemorySearchManagerMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
manager: null,
|
||||
debug: managerDebug,
|
||||
error: undefined,
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("./memory/index.js", () => ({
|
||||
closeAllMemorySearchManagers: vi.fn(async () => {}),
|
||||
closeMemorySearchManager: vi.fn(async () => {}),
|
||||
getMemorySearchManager: getMemorySearchManagerMock,
|
||||
}));
|
||||
|
||||
import { memoryRuntime } from "./runtime-provider.js";
|
||||
|
||||
describe("memoryRuntime", () => {
|
||||
it("preserves manager debug metadata", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
const result = await memoryRuntime.getMemorySearchManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(result.debug).toEqual(managerDebug);
|
||||
expect(getMemorySearchManagerMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
|
||||
export const memoryRuntime: MemoryPluginRuntime = {
|
||||
async getMemorySearchManager(params) {
|
||||
const { manager, error } = await getMemorySearchManager(params);
|
||||
const { manager, debug, error } = await getMemorySearchManager(params);
|
||||
return {
|
||||
manager,
|
||||
debug,
|
||||
error,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -67,18 +67,28 @@ export async function getMemoryManagerContextWithPurpose(params: {
|
||||
}): Promise<
|
||||
| {
|
||||
manager: NonNullable<MemorySearchManagerResult["manager"]>;
|
||||
debug?: NonNullable<MemorySearchManagerResult["debug"]>;
|
||||
}
|
||||
| {
|
||||
error: string | undefined;
|
||||
}
|
||||
> {
|
||||
const { getMemorySearchManager } = await loadMemoryToolRuntime();
|
||||
const { manager, error } = await getMemorySearchManager({
|
||||
const startedAt = Date.now();
|
||||
const { manager, debug, error } = await getMemorySearchManager({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
purpose: params.purpose,
|
||||
});
|
||||
return manager ? { manager } : { error };
|
||||
return manager
|
||||
? {
|
||||
manager,
|
||||
debug: {
|
||||
...debug,
|
||||
managerMs: debug?.managerMs ?? Math.max(0, Date.now() - startedAt),
|
||||
},
|
||||
}
|
||||
: { error };
|
||||
}
|
||||
|
||||
export function createMemoryTool(params: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Memory Core tests cover tools plugin behavior.
|
||||
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getMemoryCloseMockCalls,
|
||||
@@ -381,6 +382,95 @@ describe("memory_search unavailable payloads", () => {
|
||||
expect(searchCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("merges qmd runtime debug across zero-hit retry attempts", async () => {
|
||||
setMemoryBackend("qmd");
|
||||
let searchCalls = 0;
|
||||
setMemorySearchImpl(async (opts) => {
|
||||
searchCalls += 1;
|
||||
if (searchCalls === 1) {
|
||||
opts?.onDebug?.({
|
||||
backend: "qmd",
|
||||
configuredMode: "search",
|
||||
effectiveMode: "search",
|
||||
qmd: {
|
||||
collectionValidation: {
|
||||
cacheState: "hit",
|
||||
elapsedMs: 2,
|
||||
collectionCount: 2,
|
||||
listCalls: 0,
|
||||
showCalls: 0,
|
||||
},
|
||||
multiCollectionProbe: {
|
||||
cacheState: "hit",
|
||||
elapsedMs: 1,
|
||||
supported: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return [];
|
||||
}
|
||||
opts?.onDebug?.({
|
||||
backend: "qmd",
|
||||
configuredMode: "search",
|
||||
effectiveMode: "query",
|
||||
fallback: "unsupported-search-flags",
|
||||
qmd: {
|
||||
searchPlan: {
|
||||
command: "query",
|
||||
collectionCount: 2,
|
||||
groupCount: 2,
|
||||
sources: ["memory", "sessions"],
|
||||
},
|
||||
},
|
||||
});
|
||||
return [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Thread-hidden codename: ORBIT-22.",
|
||||
source: "memory" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { backend: "qmd", citations: "off" },
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("zero-hit-debug-retry", {
|
||||
query: "hidden thread codename",
|
||||
});
|
||||
const details = result.details as {
|
||||
debug?: {
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
qmd?: MemorySearchRuntimeDebug["qmd"];
|
||||
};
|
||||
};
|
||||
|
||||
expect(searchCalls).toBe(2);
|
||||
expect(details.debug?.effectiveMode).toBe("query");
|
||||
expect(details.debug?.fallback).toBe("unsupported-search-flags");
|
||||
expect(details.debug?.qmd?.collectionValidation).toMatchObject({
|
||||
cacheState: "hit",
|
||||
collectionCount: 2,
|
||||
});
|
||||
expect(details.debug?.qmd?.multiCollectionProbe).toMatchObject({
|
||||
cacheState: "hit",
|
||||
supported: true,
|
||||
});
|
||||
expect(details.debug?.qmd?.searchPlan).toEqual({
|
||||
command: "query",
|
||||
collectionCount: 2,
|
||||
groupCount: 2,
|
||||
sources: ["memory", "sessions"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unavailable metadata when the index identity is paused", async () => {
|
||||
let searchCalls = 0;
|
||||
setMemorySearchImpl(async () => {
|
||||
@@ -422,6 +512,14 @@ describe("memory_search unavailable payloads", () => {
|
||||
configuredMode: opts.qmdSearchModeOverride ?? "query",
|
||||
effectiveMode: "query",
|
||||
fallback: "unsupported-search-flags",
|
||||
qmd: {
|
||||
searchPlan: {
|
||||
command: "query",
|
||||
collectionCount: 2,
|
||||
groupCount: 2,
|
||||
sources: ["memory", "sessions"],
|
||||
},
|
||||
},
|
||||
});
|
||||
return [
|
||||
{
|
||||
@@ -470,6 +568,18 @@ describe("memory_search unavailable payloads", () => {
|
||||
fallback?: unknown;
|
||||
hits?: unknown;
|
||||
searchMs?: number;
|
||||
toolMs?: number;
|
||||
managerMs?: number;
|
||||
outsideSearchMs?: number;
|
||||
managerCacheState?: unknown;
|
||||
qmd?: {
|
||||
searchPlan?: {
|
||||
command?: unknown;
|
||||
collectionCount?: unknown;
|
||||
groupCount?: unknown;
|
||||
sources?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(details.mode).toBe("query");
|
||||
@@ -479,6 +589,94 @@ describe("memory_search unavailable payloads", () => {
|
||||
expect(details.debug?.fallback).toBe("unsupported-search-flags");
|
||||
expect(details.debug?.hits).toBe(1);
|
||||
expect(details.debug?.searchMs).toBeGreaterThanOrEqual(0);
|
||||
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
|
||||
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
|
||||
expect(details.debug?.managerMs).toBeGreaterThanOrEqual(0);
|
||||
expect(details.debug?.managerCacheState).toBeUndefined();
|
||||
expect(details.debug?.qmd?.searchPlan).toEqual({
|
||||
command: "query",
|
||||
collectionCount: 2,
|
||||
groupCount: 2,
|
||||
sources: ["memory", "sessions"],
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
setMemorySearchImpl(async () => [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
score: 0.9,
|
||||
snippet: "ramen",
|
||||
source: "memory",
|
||||
},
|
||||
]);
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { backend: "qmd" },
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("manager-debug", { query: "favorite food" });
|
||||
const details = result.details as {
|
||||
debug?: {
|
||||
backend?: string;
|
||||
managerMs?: number;
|
||||
toolMs?: number;
|
||||
outsideSearchMs?: number;
|
||||
managerCacheState?: string;
|
||||
hits?: number;
|
||||
searchMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
expect(details.debug?.backend).toBe("qmd");
|
||||
expect(details.debug?.managerMs).toBe(17);
|
||||
expect(details.debug?.toolMs).toBeGreaterThanOrEqual(details.debug?.searchMs ?? 0);
|
||||
expect(details.debug?.outsideSearchMs).toBeGreaterThanOrEqual(0);
|
||||
expect(details.debug?.managerCacheState).toBe("cached-full-hit");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,12 +44,35 @@ type MemorySearchToolResult =
|
||||
| MemoryCorpusSearchResult;
|
||||
type MemoryManagerContext = Awaited<ReturnType<typeof getMemoryManagerContextWithPurpose>>;
|
||||
type ActiveMemoryManagerContext = Extract<MemoryManagerContext, { manager: unknown }>;
|
||||
type QmdRuntimeDebug = NonNullable<MemorySearchRuntimeDebug["qmd"]>;
|
||||
|
||||
const MEMORY_SEARCH_TOOL_TIMEOUT_MS = 15_000;
|
||||
const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000;
|
||||
|
||||
const memorySearchToolCooldowns = new Map<string, { until: number; error: string }>();
|
||||
|
||||
function mergeQmdRuntimeDebug(
|
||||
entries: readonly MemorySearchRuntimeDebug[],
|
||||
): MemorySearchRuntimeDebug["qmd"] | undefined {
|
||||
const merged: QmdRuntimeDebug = {};
|
||||
for (const entry of entries) {
|
||||
const qmd = entry.qmd;
|
||||
if (!qmd) {
|
||||
continue;
|
||||
}
|
||||
if (!merged.collectionValidation && qmd.collectionValidation) {
|
||||
merged.collectionValidation = qmd.collectionValidation;
|
||||
}
|
||||
if (qmd.multiCollectionProbe) {
|
||||
merged.multiCollectionProbe = qmd.multiCollectionProbe;
|
||||
}
|
||||
if (qmd.searchPlan) {
|
||||
merged.searchPlan = qmd.searchPlan;
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function resolveMemorySearchToolCooldownKey(options: {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
@@ -415,6 +438,7 @@ export function createMemorySearchTool(options: {
|
||||
const outcome = await runMemorySearchToolWithDeadline({
|
||||
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
|
||||
run: async (deadlineSignal) => {
|
||||
const toolStartedAt = Date.now();
|
||||
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
|
||||
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
|
||||
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
|
||||
@@ -471,13 +495,20 @@ export function createMemorySearchTool(options: {
|
||||
let fallback: unknown;
|
||||
let searchMode: string | undefined;
|
||||
let pausedIndexIdentityReason: string | undefined;
|
||||
let managerMs: number | undefined;
|
||||
let managerCacheState: string | undefined;
|
||||
let searchDebug:
|
||||
| {
|
||||
backend: string;
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
toolMs?: number;
|
||||
managerMs?: number;
|
||||
outsideSearchMs?: number;
|
||||
searchMs: number;
|
||||
managerCacheState?: string;
|
||||
qmd?: MemorySearchRuntimeDebug["qmd"];
|
||||
hits: number;
|
||||
}
|
||||
| undefined;
|
||||
@@ -506,6 +537,8 @@ export function createMemorySearchTool(options: {
|
||||
},
|
||||
...(searchSources ? { sources: searchSources } : {}),
|
||||
};
|
||||
managerMs = memory.debug?.managerMs;
|
||||
managerCacheState = memory.debug?.managerCacheState;
|
||||
try {
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
} catch (error) {
|
||||
@@ -522,6 +555,8 @@ export function createMemorySearchTool(options: {
|
||||
if ("error" in refreshed) {
|
||||
throw error;
|
||||
}
|
||||
managerMs = refreshed.debug?.managerMs;
|
||||
managerCacheState = refreshed.debug?.managerCacheState;
|
||||
activeMemory = refreshed;
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
}
|
||||
@@ -580,7 +615,9 @@ export function createMemorySearchTool(options: {
|
||||
model = status.model;
|
||||
fallback = status.fallback;
|
||||
const latestDebug = runtimeDebug.at(-1);
|
||||
const qmdDebug = mergeQmdRuntimeDebug(runtimeDebug);
|
||||
searchMode = latestDebug?.effectiveMode;
|
||||
const searchMs = Math.max(0, Date.now() - searchStartedAt);
|
||||
searchDebug = {
|
||||
backend: status.backend,
|
||||
configuredMode: latestDebug?.configuredMode,
|
||||
@@ -589,7 +626,10 @@ export function createMemorySearchTool(options: {
|
||||
? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode)
|
||||
: "n/a",
|
||||
fallback: latestDebug?.fallback,
|
||||
searchMs: Math.max(0, Date.now() - searchStartedAt),
|
||||
managerMs,
|
||||
searchMs,
|
||||
managerCacheState,
|
||||
qmd: qmdDebug,
|
||||
hits: rawResults.length,
|
||||
};
|
||||
});
|
||||
@@ -620,6 +660,14 @@ export function createMemorySearchTool(options: {
|
||||
maxResults: effectiveMax,
|
||||
balanceCorpora: requestedCorpus === "all",
|
||||
});
|
||||
if (searchDebug) {
|
||||
const finalToolMs = Math.max(0, Date.now() - toolStartedAt);
|
||||
searchDebug = {
|
||||
...searchDebug,
|
||||
toolMs: finalToolMs,
|
||||
outsideSearchMs: Math.max(0, finalToolMs - searchDebug.searchMs),
|
||||
};
|
||||
}
|
||||
return jsonResult({
|
||||
results,
|
||||
provider,
|
||||
|
||||
@@ -197,6 +197,7 @@ export const signalApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
|
||||
conversationKey: entry.conversationKey,
|
||||
messageId: entry.messageId,
|
||||
approvalId: request.id,
|
||||
approvalKind: view.approvalKind,
|
||||
allowedDecisions: pendingPayload.reactionPayload.allowedDecisions,
|
||||
targetAuthorKeys: entry.targetAuthorKeys,
|
||||
route: {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover approval reactions plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSignalApprovalReactionHintToText,
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
buildSignalApprovalReactionHint,
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
maybeResolveSignalApprovalReaction,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
registerSignalApprovalReactionTarget,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
@@ -78,7 +82,220 @@ describe("Signal approval reactions", () => {
|
||||
).toBe(prompt);
|
||||
});
|
||||
|
||||
it("registers target-mode outbound approval prompts for reactions", async () => {
|
||||
it("registers delivered structured approval payloads for reactions", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalSlug: "exec-str",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000012",
|
||||
toJid: "+15551230000",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000012",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-structured-approval",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register metadata-only approval payloads without visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-hidden-reaction",
|
||||
approvalSlug: "exec-hid",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf hidden",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000015",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000015",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers only delivered chunks that contain visible reaction hints", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
approvalSlug: "exec-ch",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf chunked",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000016",
|
||||
meta: {
|
||||
signalVisibleText: "Exec approval required\n\nReact with:\n\n👍 Allow Once\n👎 Deny",
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000017",
|
||||
meta: {
|
||||
signalVisibleText: "Continuation chunk without controls",
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000016",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "exec-chunked-reaction",
|
||||
decision: "allow-once",
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000017",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("registers delivered structured plugin approval payloads using metadata kind", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
@@ -93,70 +310,106 @@ describe("Signal approval reactions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const text =
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
|
||||
const textWithHint = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
const payload = buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: "plugin-structured-approval",
|
||||
request: {
|
||||
title: "Sensitive plugin action",
|
||||
description: "Needs approval",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
createdAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
},
|
||||
nowMs: 1_000,
|
||||
});
|
||||
const deliveredPayload = addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
text,
|
||||
payload,
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(textWithHint).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
messageId: "1700000000009",
|
||||
text: textWithHint,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000013",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const handled = await maybeResolveSignalApprovalReaction({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000009",
|
||||
reactionKey: "👍",
|
||||
actorId: "+15551230000",
|
||||
targetAuthor: "+15550009999",
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
approvalId: "plugin:abc",
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000013",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin-structured-approval",
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
senderId: "+15551230000",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps target-mode outbound prompts manual when the target route is disabled", () => {
|
||||
const text =
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny";
|
||||
it("does not register delivered structured approval payloads without explicit approvers", () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-no-approvers",
|
||||
approvalSlug: "exec-no",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const deliveredPayload = {
|
||||
...payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: payload.text ?? "",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
}),
|
||||
};
|
||||
|
||||
expect(
|
||||
appendSignalApprovalReactionHintForOutboundMessage({
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: {
|
||||
channels: { signal: { allowFrom: ["+15551230000"] } },
|
||||
channels: {
|
||||
signal: {},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: false,
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
to: "+15551230000",
|
||||
text,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000014",
|
||||
},
|
||||
],
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).toBe(text);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("registers reaction state when only allow-always is available", async () => {
|
||||
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
type ApprovalReactionDecisionBinding,
|
||||
type ApprovalReactionTargetRecord,
|
||||
} from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import {
|
||||
getExecApprovalReplyMetadata,
|
||||
type ExecApprovalReplyDecision,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -21,7 +25,7 @@ import { looksLikeUuid } from "./identity.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { getOptionalSignalRuntime } from "./runtime.js";
|
||||
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions";
|
||||
const PERSISTENT_NAMESPACE = "signal.approval-reactions.v2";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -58,6 +62,19 @@ type SignalApprovalReactionTarget = ApprovalReactionTargetRecord<SignalApprovalR
|
||||
route: SignalApprovalReactionRoute;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryTarget = {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type SignalApprovalDeliveryResult = {
|
||||
channel?: string;
|
||||
messageId?: string | null;
|
||||
toJid?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let resolverRuntimePromise: Promise<typeof import("./approval-resolver.js")> | undefined;
|
||||
|
||||
const signalApprovalReactionTargets =
|
||||
@@ -320,7 +337,7 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
text: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
}): string {
|
||||
if (/(^|\n)React with:\s*(\n|$)/i.test(params.text)) {
|
||||
if (hasSignalApprovalReactionHintText(params.text)) {
|
||||
return params.text;
|
||||
}
|
||||
const hint = buildSignalApprovalReactionHint(params.allowedDecisions);
|
||||
@@ -329,40 +346,8 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
: params.text;
|
||||
}
|
||||
|
||||
function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "always") {
|
||||
return "allow-always";
|
||||
}
|
||||
if (normalized === "allow-once" || normalized === "allow-always" || normalized === "deny") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractSignalApprovalPromptBinding(text: string): {
|
||||
approvalId: string;
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
} | null {
|
||||
const allowedDecisions: ExecApprovalReplyDecision[] = [];
|
||||
let approvalId = "";
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (approvalId && match[1] !== approvalId) {
|
||||
continue;
|
||||
}
|
||||
approvalId ||= match[1];
|
||||
for (const decisionText of match[2].split(/[\s|,]+/)) {
|
||||
const decision = normalizeApprovalDecision(decisionText);
|
||||
if (decision && !allowedDecisions.includes(decision)) {
|
||||
allowedDecisions.push(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
return approvalId && allowedDecisions.length > 0 ? { approvalId, allowedDecisions } : null;
|
||||
function hasSignalApprovalReactionHintText(text?: string | null): boolean {
|
||||
return /(^|\n)React with:\s*(\n|$)/i.test(text ?? "");
|
||||
}
|
||||
|
||||
function buildTargetRoute(params: {
|
||||
@@ -370,6 +355,7 @@ function buildTargetRoute(params: {
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): Extract<SignalApprovalReactionRoute, { deliveryMode: "target" }> | null {
|
||||
@@ -393,7 +379,7 @@ function buildTargetRoute(params: {
|
||||
return isSignalApprovalReactionRouteStillEnabled({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
approvalKind: resolveApprovalKindFromId(params.approvalId),
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(params.approvalId),
|
||||
route,
|
||||
},
|
||||
})
|
||||
@@ -401,64 +387,6 @@ function buildTargetRoute(params: {
|
||||
: null;
|
||||
}
|
||||
|
||||
export function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): boolean {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): string {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (
|
||||
!binding ||
|
||||
!shouldAppendSignalApprovalReactionHintForOutboundMessage({
|
||||
...params,
|
||||
text: params.text,
|
||||
})
|
||||
) {
|
||||
return params.text;
|
||||
}
|
||||
return addSignalApprovalReactionHintToText({
|
||||
text: params.text,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasSignalApprovalReactionApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -471,6 +399,7 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
conversationKey: string;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
targetAuthorKeys: readonly string[];
|
||||
route: SignalApprovalReactionRoute;
|
||||
@@ -521,7 +450,7 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
} satisfies SignalApprovalReactionRoute);
|
||||
const target: SignalApprovalReactionTarget = {
|
||||
approvalId,
|
||||
approvalKind: resolveApprovalKindFromId(approvalId),
|
||||
approvalKind: params.approvalKind ?? resolveApprovalKindFromId(approvalId),
|
||||
allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
@@ -530,50 +459,142 @@ export function registerSignalApprovalReactionTarget(params: {
|
||||
return target;
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTargetForOutboundMessage(params: {
|
||||
export function addSignalApprovalReactionHintToStructuredPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
payload: ReplyPayload;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}): ReplyPayload | null {
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.to);
|
||||
if (!conversationKey) {
|
||||
return false;
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return null;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
});
|
||||
if (!route || !params.payload.text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...params.payload,
|
||||
text: addSignalApprovalReactionHintToText({
|
||||
text: params.payload.text,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readSignalDeliveryVisibleText(result: SignalApprovalDeliveryResult): string | null {
|
||||
const meta = result.meta;
|
||||
const visibleText = meta?.signalVisibleText ?? meta?.visibleText;
|
||||
return typeof visibleText === "string" ? visibleText : null;
|
||||
}
|
||||
|
||||
function listDeliveredSignalMessageIdsWithVisibleHint(params: {
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
}): string[] {
|
||||
const signalResults = params.results.filter(
|
||||
(result) => !result.channel || normalizeLowercaseStringOrEmpty(result.channel) === "signal",
|
||||
);
|
||||
const resultsWithVisibleText = signalResults.filter(
|
||||
(result) => readSignalDeliveryVisibleText(result) !== null,
|
||||
);
|
||||
const candidates = resultsWithVisibleText.length > 0 ? resultsWithVisibleText : signalResults;
|
||||
if (resultsWithVisibleText.length === 0 && candidates.length !== 1) {
|
||||
return [];
|
||||
}
|
||||
const ids = candidates
|
||||
.filter((result) =>
|
||||
resultsWithVisibleText.length > 0
|
||||
? hasSignalApprovalReactionHintText(readSignalDeliveryVisibleText(result))
|
||||
: hasSignalApprovalReactionHintText(params.payload.text),
|
||||
)
|
||||
.map((result) => normalizeOptionalString(result.messageId))
|
||||
.filter((messageId): messageId is string => Boolean(messageId && messageId !== "unknown"));
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTargetForDeliveredPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: SignalApprovalDeliveryTarget;
|
||||
payload: ReplyPayload;
|
||||
results: readonly SignalApprovalDeliveryResult[];
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
if (normalizeLowercaseStringOrEmpty(params.target.channel) !== "signal") {
|
||||
return false;
|
||||
}
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata?.allowedDecisions || metadata.allowedDecisions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionHintText(params.payload.text)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.target.accountId })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.target.to);
|
||||
if (!conversationKey) {
|
||||
return false;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId,
|
||||
to: params.target.to,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
agentId: metadata.agentId,
|
||||
sessionKey: metadata.sessionKey,
|
||||
});
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: params.accountId,
|
||||
conversationKey,
|
||||
messageId: params.messageId,
|
||||
approvalId: binding.approvalId,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
targetAuthorKeys: resolveSignalApprovalTargetAuthorKeys(params),
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
);
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let registered = false;
|
||||
for (const messageId of listDeliveredSignalMessageIdsWithVisibleHint({
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
})) {
|
||||
registered =
|
||||
Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: normalizeAccountId(params.target.accountId ?? undefined),
|
||||
conversationKey,
|
||||
messageId,
|
||||
approvalId: metadata.approvalId,
|
||||
approvalKind: metadata.approvalKind,
|
||||
allowedDecisions: metadata.allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
) || registered;
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
export function unregisterSignalApprovalReactionTarget(params: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Signal plugin module implements channel behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -40,10 +41,12 @@ import {
|
||||
} from "./shared.js";
|
||||
type SignalSendFn = typeof import("./send.runtime.js").sendMessageSignal;
|
||||
type SignalProbe = import("./probe.js").SignalProbe;
|
||||
type SignalApprovalReactionsModule = typeof import("./approval-reactions.js");
|
||||
|
||||
let signalMonitorModulePromise: Promise<typeof import("./monitor.js")> | null = null;
|
||||
let signalProbeModulePromise: Promise<typeof import("./probe.js")> | null = null;
|
||||
let signalSendRuntimePromise: Promise<typeof import("./send.runtime.js")> | null = null;
|
||||
let signalApprovalReactionsModulePromise: Promise<SignalApprovalReactionsModule> | null = null;
|
||||
|
||||
async function loadSignalMonitorModule() {
|
||||
signalMonitorModulePromise ??= import("./monitor.js");
|
||||
@@ -60,6 +63,11 @@ async function loadSignalSendRuntime() {
|
||||
return await signalSendRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadSignalApprovalReactionsModule() {
|
||||
signalApprovalReactionsModulePromise ??= import("./approval-reactions.js");
|
||||
return await signalApprovalReactionsModulePromise;
|
||||
}
|
||||
|
||||
async function resolveSignalSendContext(params: {
|
||||
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
|
||||
accountId?: string;
|
||||
@@ -102,6 +110,20 @@ type SignalMessageContextExtras = {
|
||||
deps?: { [channelId: string]: unknown };
|
||||
};
|
||||
|
||||
function attachSignalVisibleText<T extends object>(result: T, visibleText: string) {
|
||||
const meta =
|
||||
"meta" in result && result.meta && typeof result.meta === "object"
|
||||
? (result.meta as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...result,
|
||||
meta: {
|
||||
...meta,
|
||||
signalVisibleText: visibleText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const signalMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "signal",
|
||||
durableFinal: {
|
||||
@@ -224,7 +246,7 @@ async function sendFormattedSignalText(ctx: {
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
results.push(result);
|
||||
results.push(attachSignalVisibleText(result, chunk.text));
|
||||
}
|
||||
return attachChannelToResults("signal", results);
|
||||
}
|
||||
@@ -267,7 +289,49 @@ async function sendFormattedSignalMedia(ctx: {
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
});
|
||||
return attachChannelToResult("signal", result);
|
||||
return attachChannelToResult("signal", attachSignalVisibleText(result, formatted.text));
|
||||
}
|
||||
|
||||
async function registerDeliveredSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["afterDeliverPayload"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.target.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return;
|
||||
}
|
||||
const { registerSignalApprovalReactionTargetForDeliveredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: params.target,
|
||||
payload: params.payload,
|
||||
results: params.results,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSignalApprovalPayloadForReactions(
|
||||
params: Parameters<NonNullable<ChannelOutboundAdapter["renderPresentation"]>>[0],
|
||||
) {
|
||||
const account = resolveSignalAccount({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
});
|
||||
if (!account.config.account) {
|
||||
return null;
|
||||
}
|
||||
const { addSignalApprovalReactionHintToStructuredPayload } =
|
||||
await loadSignalApprovalReactionsModule();
|
||||
return addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.ctx.cfg,
|
||||
accountId: params.ctx.accountId ?? undefined,
|
||||
to: params.ctx.to,
|
||||
payload: params.payload,
|
||||
targetAuthor: account.config.account,
|
||||
});
|
||||
}
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
@@ -404,6 +468,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
|
||||
payload,
|
||||
hint,
|
||||
}),
|
||||
afterDeliverPayload: async (params) =>
|
||||
await registerDeliveredSignalApprovalPayloadForReactions(params),
|
||||
renderPresentation: async (params) => await renderSignalApprovalPayloadForReactions(params),
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) =>
|
||||
await sendFormattedSignalText({
|
||||
cfg,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
// Signal tests cover core plugin behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
@@ -6,6 +7,10 @@ import {
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalPlugin } from "./channel.js";
|
||||
import * as clientModule from "./client-adapter.js";
|
||||
import { classifySignalCliLogLine } from "./daemon.js";
|
||||
@@ -264,6 +269,143 @@ describe("signal outbound", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("registers structured approval payloads for reactions after delivery", async () => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalSlug: "exec-aft",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
|
||||
await signalPlugin.outbound?.afterDeliverPayload?.({
|
||||
cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
},
|
||||
payload: rendered!,
|
||||
results: [
|
||||
{
|
||||
channel: "signal",
|
||||
messageId: "1700000000099",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551230000",
|
||||
messageId: "1700000000099",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550009999",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-after-delivery",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: "+15551230000",
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders reaction hints only from structured approval payloads", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: "+15550009999",
|
||||
allowFrom: ["+15551230000"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551230000" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-rendered-approval",
|
||||
approvalSlug: "exec-ren",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf test",
|
||||
host: "gateway",
|
||||
});
|
||||
const rendered = await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload,
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered?.text).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
expect(
|
||||
await signalPlugin.outbound?.renderPresentation?.({
|
||||
payload: {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-rendered-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-rendered-approval allow-once|deny",
|
||||
].join("\n"),
|
||||
presentation: payload.presentation,
|
||||
},
|
||||
presentation: payload.presentation!,
|
||||
ctx: {
|
||||
cfg,
|
||||
to: "+15551230000",
|
||||
text: payload.text ?? "",
|
||||
accountId: "default",
|
||||
payload,
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => {
|
||||
const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1";
|
||||
|
||||
127
extensions/signal/src/monitor.approval-reply-delivery.test.ts
Normal file
127
extensions/signal/src/monitor.approval-reply-delivery.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSignalApprovalReactionTargetsForTest,
|
||||
resolveSignalApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageSignal: sendMocks.sendMessageSignal,
|
||||
};
|
||||
});
|
||||
|
||||
const { deliverReplies } = await import("./monitor.js");
|
||||
|
||||
const botAccount = "+15550009999";
|
||||
const approver = "+15551230000";
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
account: botAccount,
|
||||
allowFrom: [approver],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: approver }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
async function deliverReplyPayload(payload: ReplyPayload) {
|
||||
await deliverReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target: approver,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
account: botAccount,
|
||||
accountId: "default",
|
||||
runtime: { log: vi.fn() } as never,
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
textLimit: 4000,
|
||||
chunkMode: "length",
|
||||
});
|
||||
}
|
||||
|
||||
describe("Signal monitor approval reply delivery", () => {
|
||||
beforeEach(() => {
|
||||
clearSignalApprovalReactionTargetsForTest();
|
||||
sendMocks.sendMessageSignal.mockReset().mockResolvedValue({
|
||||
messageId: "1700000000200",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reaction hints and registers structured approval replies delivered by the monitor", async () => {
|
||||
const payload = buildExecApprovalPendingReplyPayload({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalSlug: "exec-mon",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
command: "printf monitor",
|
||||
host: "gateway",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
});
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).toContain("React with:\n\n👍 Allow Once\n👎 Deny");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-monitor-structured",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
route: {
|
||||
deliveryMode: "target",
|
||||
to: approver,
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:signal:direct:+15551230000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bind ordinary monitor replies that quote approval commands", async () => {
|
||||
const payload = {
|
||||
text: [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-monitor-quoted",
|
||||
"",
|
||||
"Reply with: /approve exec-monitor-quoted allow-once|deny",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
await deliverReplyPayload(payload);
|
||||
|
||||
const sentText = String(sendMocks.sendMessageSignal.mock.calls[0]?.[1] ?? "");
|
||||
expect(sentText).not.toContain("React with:");
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: approver,
|
||||
messageId: "1700000000200",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: botAccount,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,10 @@ import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js";
|
||||
import {
|
||||
addSignalApprovalReactionHintToStructuredPayload,
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest, signalCheck } from "./client-adapter.js";
|
||||
import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js";
|
||||
import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js";
|
||||
@@ -354,7 +358,7 @@ async function fetchAttachment(params: {
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
async function deliverReplies(params: {
|
||||
export async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
@@ -369,32 +373,79 @@ async function deliverReplies(params: {
|
||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const reply = resolveSendableOutboundReplyParts(payload);
|
||||
const deliveryResults: Array<{
|
||||
channel: "signal";
|
||||
messageId: string;
|
||||
meta: { signalVisibleText: string };
|
||||
}> = [];
|
||||
const deliveredPayload =
|
||||
addSignalApprovalReactionHintToStructuredPayload({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
to: target,
|
||||
payload,
|
||||
targetAuthor: account,
|
||||
}) ?? payload;
|
||||
const reply = resolveSendableOutboundReplyParts(deliveredPayload);
|
||||
const recordDeliveryResult = (
|
||||
result: Awaited<ReturnType<typeof sendMessageSignal>>,
|
||||
visibleText: string,
|
||||
) => {
|
||||
const messageId =
|
||||
typeof result?.messageId === "string" && result.messageId.trim()
|
||||
? result.messageId.trim()
|
||||
: null;
|
||||
if (messageId) {
|
||||
deliveryResults.push({
|
||||
channel: "signal",
|
||||
messageId,
|
||||
meta: { signalVisibleText: visibleText },
|
||||
});
|
||||
}
|
||||
};
|
||||
const delivered = await deliverTextOrMediaReply({
|
||||
payload,
|
||||
payload: deliveredPayload,
|
||||
text: reply.text,
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, chunk, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
chunk,
|
||||
);
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
await sendMessageSignal(target, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
const visibleText = caption ?? "";
|
||||
recordDeliveryResult(
|
||||
await sendMessageSignal(target, visibleText, {
|
||||
cfg: params.cfg,
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId,
|
||||
}),
|
||||
visibleText,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (delivered !== "empty") {
|
||||
registerSignalApprovalReactionTargetForDeliveredPayload({
|
||||
cfg: params.cfg,
|
||||
target: {
|
||||
channel: "signal",
|
||||
to: target,
|
||||
accountId,
|
||||
},
|
||||
payload: deliveredPayload,
|
||||
results: deliveryResults,
|
||||
targetAuthor: account,
|
||||
});
|
||||
runtime.log?.(`delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,4 +129,73 @@ describe("sendMessageSignal receipts", () => {
|
||||
expect(result.messageId).toBe("unknown");
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound approval-looking text", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567892 });
|
||||
const text = [
|
||||
"Here is the command you asked about:",
|
||||
"/approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add approval reactions to ordinary outbound text quoting a full prompt", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567893 });
|
||||
const text = [
|
||||
"The docs show this example:",
|
||||
"Exec approval required",
|
||||
"ID: exec-live-approval",
|
||||
"",
|
||||
"Reply with: /approve exec-live-approval allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, {
|
||||
cfg: {
|
||||
...SIGNAL_TEST_CFG,
|
||||
channels: {
|
||||
signal: {
|
||||
...SIGNAL_TEST_CFG.channels.signal,
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(signalRpcRequestMock).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({ message: text }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,6 @@ import { resolveOutboundAttachmentFromUrl } from "openclaw/plugin-sdk/media-runt
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import {
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest } from "./client-adapter.js";
|
||||
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { resolveSignalRpcContext } from "./rpc-context.js";
|
||||
@@ -184,14 +180,7 @@ export async function sendMessageSignal(
|
||||
});
|
||||
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||
const target = parseTarget(to);
|
||||
const outboundText = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
text: text ?? "",
|
||||
targetAuthor: account,
|
||||
});
|
||||
let message = outboundText;
|
||||
let message = text ?? "";
|
||||
let messageFromPlaceholder = false;
|
||||
let textStyles: SignalTextStyleRange[] = [];
|
||||
const textMode = opts.textMode ?? "markdown";
|
||||
@@ -273,14 +262,6 @@ export async function sendMessageSignal(
|
||||
});
|
||||
const timestamp = result?.timestamp;
|
||||
const messageId = timestamp ? String(timestamp) : "unknown";
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
messageId,
|
||||
text: outboundText,
|
||||
targetAuthor: account,
|
||||
});
|
||||
return {
|
||||
messageId,
|
||||
timestamp,
|
||||
|
||||
@@ -55,11 +55,39 @@ export type MemorySyncParams = {
|
||||
};
|
||||
|
||||
/** Runtime backend/mode diagnostics for memory search. */
|
||||
export type MemorySearchRuntimeQmdCollectionValidationDebug = {
|
||||
cacheState?: "hit" | "miss" | "write" | "bypass-force" | "error";
|
||||
elapsedMs: number;
|
||||
collectionCount: number;
|
||||
listCalls?: number;
|
||||
showCalls?: number;
|
||||
};
|
||||
|
||||
export type MemorySearchRuntimeQmdMultiCollectionProbeDebug = {
|
||||
cacheState?: "hit" | "miss" | "write" | "error";
|
||||
elapsedMs: number;
|
||||
supported: boolean;
|
||||
};
|
||||
|
||||
export type MemorySearchRuntimeQmdSearchPlanDebug = {
|
||||
command?: "query" | "search" | "vsearch";
|
||||
collectionCount?: number;
|
||||
groupCount?: number;
|
||||
sources?: MemorySource[];
|
||||
};
|
||||
|
||||
export type MemorySearchRuntimeQmdDebug = {
|
||||
collectionValidation?: MemorySearchRuntimeQmdCollectionValidationDebug;
|
||||
multiCollectionProbe?: MemorySearchRuntimeQmdMultiCollectionProbeDebug;
|
||||
searchPlan?: MemorySearchRuntimeQmdSearchPlanDebug;
|
||||
};
|
||||
|
||||
export type MemorySearchRuntimeDebug = {
|
||||
backend: "builtin" | "qmd";
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
qmd?: MemorySearchRuntimeQmdDebug;
|
||||
};
|
||||
|
||||
/** Result of reading a memory file, optionally paginated/truncated. */
|
||||
|
||||
@@ -957,6 +957,8 @@ describe("buildStatusReply subagent summary", () => {
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_OAUTH_TOKEN: undefined,
|
||||
},
|
||||
skipSessionCleanup: true,
|
||||
skipHomeCleanup: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1042,6 +1044,8 @@ describe("buildStatusReply subagent summary", () => {
|
||||
OPENAI_API_KEY: undefined,
|
||||
OPENAI_OAUTH_TOKEN: undefined,
|
||||
},
|
||||
skipSessionCleanup: true,
|
||||
skipHomeCleanup: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1066,66 +1070,69 @@ describe("buildStatusReply subagent summary", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(async (dir) => {
|
||||
saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" });
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfile({ dir, profileId: "work", provider: "openai" });
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-synthetic-usage",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("Runtime: OpenAI Codex");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
if (!providerUsageCall) {
|
||||
throw new Error("expected provider usage summary call for synthetic Codex auth");
|
||||
}
|
||||
expect(providerUsageCall[0]).toMatchObject({
|
||||
timeoutMs: 8000,
|
||||
providers: ["openai"],
|
||||
auth: [
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "work",
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-synthetic-usage",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "work",
|
||||
},
|
||||
],
|
||||
config: expect.objectContaining({
|
||||
agents: expect.objectContaining({
|
||||
defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }),
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("Runtime: OpenAI Codex");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
if (!providerUsageCall) {
|
||||
throw new Error("expected provider usage summary call for synthetic Codex auth");
|
||||
}
|
||||
expect(providerUsageCall[0]).toMatchObject({
|
||||
timeoutMs: 8000,
|
||||
providers: ["openai"],
|
||||
auth: [
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "work",
|
||||
},
|
||||
],
|
||||
config: expect.objectContaining({
|
||||
agents: expect.objectContaining({
|
||||
defaults: expect.objectContaining({ agentRuntime: { id: "codex" } }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards legacy Codex profile providers to Codex synthetic usage", async () => {
|
||||
@@ -1141,14 +1148,74 @@ describe("buildStatusReply subagent summary", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(async (dir) => {
|
||||
saveStatusTestAuthProfile({
|
||||
dir,
|
||||
profileId: "openai-codex:legacy",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfile({
|
||||
dir,
|
||||
profileId: "openai-codex:legacy",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
|
||||
await buildStatusText({
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-legacy-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "openai-codex:legacy",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual([
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "openai-codex:legacy",
|
||||
},
|
||||
]);
|
||||
},
|
||||
{ skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("loads Codex synthetic usage when no local OpenAI profile label exists", async () => {
|
||||
registerStatusCodexHarness();
|
||||
providerUsageMock.loadProviderUsageSummary.mockResolvedValue({
|
||||
updatedAt: Date.now(),
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 16 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(async () => {
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
@@ -1158,9 +1225,8 @@ describe("buildStatusReply subagent summary", () => {
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-legacy-profile",
|
||||
sessionId: "sess-status-codex-no-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "openai-codex:legacy",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
@@ -1175,19 +1241,13 @@ describe("buildStatusReply subagent summary", () => {
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth",
|
||||
activeModelAuthOverride: "oauth",
|
||||
});
|
||||
|
||||
expect(normalizeTestText(text)).toContain("Usage: 5h 84% left");
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual([
|
||||
{
|
||||
...expectedCodexRuntimeUsageAuth[0],
|
||||
authProfileId: "openai-codex:legacy",
|
||||
},
|
||||
]);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1204,49 +1264,52 @@ describe("buildStatusReply subagent summary", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await withTempHome(async (dir) => {
|
||||
saveStatusTestAuthProfiles({
|
||||
dir,
|
||||
profiles: [
|
||||
{ profileId: "openai:status", provider: "openai" },
|
||||
{ profileId: "anthropic:work", provider: "anthropic" },
|
||||
],
|
||||
});
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfiles({
|
||||
dir,
|
||||
profiles: [
|
||||
{ profileId: "openai:status", provider: "openai" },
|
||||
{ profileId: "anthropic:work", provider: "anthropic" },
|
||||
],
|
||||
});
|
||||
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
agentRuntime: { id: "codex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-stale-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "anthropic:work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
});
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-codex-stale-profile",
|
||||
updatedAt: 0,
|
||||
authProfileOverride: "anthropic:work",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 32_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
});
|
||||
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
|
||||
});
|
||||
const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find(
|
||||
([params]) => params?.providers?.includes("openai"),
|
||||
);
|
||||
expect(providerUsageCall?.[0]?.auth).toEqual(expectedCodexRuntimeUsageAuth);
|
||||
},
|
||||
{ skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses active fallback provider usage for legacy fallback notices", async () => {
|
||||
@@ -1751,7 +1814,7 @@ describe("buildStatusReply subagent summary", () => {
|
||||
expect(normalized).toContain("oauth (openai:status)");
|
||||
expect(normalized).not.toContain("api-key (openai:backup)");
|
||||
},
|
||||
{ env: { OPENAI_API_KEY: undefined } },
|
||||
{ env: { OPENAI_API_KEY: undefined }, skipSessionCleanup: true, skipHomeCleanup: true },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getDaemonStatusSummary: vi.fn(),
|
||||
getNodeDaemonStatusSummary: vi.fn(),
|
||||
resolveReadOnlyChannelPluginsForConfig: vi.fn(),
|
||||
resolveModelAuthLabel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/read-only.js", () => ({
|
||||
@@ -28,6 +29,10 @@ vi.mock("../infra/provider-usage.js", () => ({
|
||||
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-auth-label.js", () => ({
|
||||
resolveModelAuthLabel: mocks.resolveModelAuthLabel,
|
||||
}));
|
||||
|
||||
vi.mock("../security/audit.runtime.js", () => ({
|
||||
runSecurityAudit: mocks.runSecurityAudit,
|
||||
}));
|
||||
@@ -45,6 +50,8 @@ function requireProviderUsageCall(): {
|
||||
timeoutMs?: number;
|
||||
config?: unknown;
|
||||
agentDir?: string;
|
||||
providers?: string[];
|
||||
auth?: Array<Record<string, unknown>>;
|
||||
} {
|
||||
const call = mocks.loadProviderUsageSummary.mock.calls[0];
|
||||
if (!call) {
|
||||
@@ -58,6 +65,8 @@ function requireProviderUsageCall(): {
|
||||
timeoutMs?: number;
|
||||
config?: unknown;
|
||||
agentDir?: string;
|
||||
providers?: string[];
|
||||
auth?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +78,7 @@ describe("status-runtime-shared", () => {
|
||||
mocks.callGateway.mockResolvedValue({ ok: true });
|
||||
mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" });
|
||||
mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" });
|
||||
mocks.resolveModelAuthLabel.mockReturnValue(undefined);
|
||||
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
|
||||
plugins: [{ id: "telegram" }],
|
||||
configuredChannelIds: ["telegram"],
|
||||
@@ -134,6 +144,176 @@ describe("status-runtime-shared", () => {
|
||||
expect(usageCall.agentDir).toContain("main");
|
||||
});
|
||||
|
||||
it("adds Codex synthetic usage for configured OpenAI Codex runtime routes without profiles", async () => {
|
||||
mocks.loadProviderUsageSummary
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [],
|
||||
error: "HTTP 429",
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 2,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 9 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [],
|
||||
error: "HTTP 429",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 9 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenNthCalledWith(2, {
|
||||
timeoutMs: 3456,
|
||||
providers: ["openai"],
|
||||
auth: [
|
||||
{
|
||||
provider: "openai",
|
||||
token: "codex-app-server",
|
||||
hookProvider: "codex",
|
||||
},
|
||||
],
|
||||
config: expect.any(Object),
|
||||
agentDir: "/tmp/status-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing OpenAI usage when Codex synthetic usage has no windows", async () => {
|
||||
mocks.loadProviderUsageSummary
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 22 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: 2,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
updatedAt: 1,
|
||||
providers: [
|
||||
{
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [{ label: "5h", usedPercent: 22 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add Codex synthetic usage for OpenAI routes pinned to OpenClaw runtime", async () => {
|
||||
await resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "openclaw" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
});
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce();
|
||||
expect(requireProviderUsageCall()).not.toHaveProperty("auth");
|
||||
});
|
||||
|
||||
it("does not add Codex synthetic usage for API-key-backed OpenAI Codex runtime routes", async () => {
|
||||
mocks.resolveModelAuthLabel.mockReturnValue("api-key (openai:api)");
|
||||
|
||||
await resolveStatusUsageSummary({
|
||||
timeoutMs: 3456,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
models: {
|
||||
"openai/gpt-5.5": { agentRuntime: { id: "codex" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir: "/tmp/status-agent",
|
||||
});
|
||||
|
||||
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce();
|
||||
expect(requireProviderUsageCall()).not.toHaveProperty("auth");
|
||||
expect(mocks.resolveModelAuthLabel).toHaveBeenCalledWith({
|
||||
provider: "openai",
|
||||
acceptedProviderIds: ["openai"],
|
||||
cfg: expect.any(Object),
|
||||
agentDir: "/tmp/status-agent",
|
||||
includeExternalProfiles: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves usage summaries with explicit agent scope", async () => {
|
||||
await resolveStatusUsageSummary({
|
||||
timeoutMs: 2345,
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
// Shared runtime probes used by status text and JSON commands.
|
||||
// Heavy modules stay lazily loaded so fast status output avoids security/provider/gateway costs.
|
||||
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
|
||||
import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
|
||||
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
||||
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../agents/openai-routing.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import {
|
||||
buildCodexSyntheticUsageAuth,
|
||||
mergeUsageSummaries,
|
||||
shouldUseCodexSyntheticUsageForRuntime,
|
||||
} from "../status/codex-synthetic-usage.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
||||
|
||||
@@ -33,6 +43,58 @@ function loadGatewayCallModule() {
|
||||
return gatewayCallModuleLoader.load();
|
||||
}
|
||||
|
||||
function resolveUsageCredentialType(authLabel?: string): "oauth" | "token" | "api_key" | undefined {
|
||||
const auth = normalizeOptionalLowercaseString(authLabel);
|
||||
if (!auth) {
|
||||
return undefined;
|
||||
}
|
||||
if (auth.startsWith("oauth")) {
|
||||
return "oauth";
|
||||
}
|
||||
if (auth.startsWith("token")) {
|
||||
return "token";
|
||||
}
|
||||
if (auth.startsWith("api-key") || auth.startsWith("api key")) {
|
||||
return "api_key";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldUseConfiguredCodexSyntheticUsage(params: {
|
||||
config: OpenClawConfig;
|
||||
agentDir: string;
|
||||
}): boolean {
|
||||
const configuredDefault = resolveDefaultModelForAgent({
|
||||
cfg: params.config,
|
||||
allowPluginNormalization: false,
|
||||
});
|
||||
const policy = resolveAgentHarnessPolicy({
|
||||
config: params.config,
|
||||
provider: configuredDefault.provider,
|
||||
modelId: configuredDefault.model,
|
||||
});
|
||||
if (
|
||||
!shouldUseCodexSyntheticUsageForRuntime({
|
||||
provider: configuredDefault.provider,
|
||||
effectiveHarness: policy.runtime,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const authLabel = resolveModelAuthLabel({
|
||||
provider: configuredDefault.provider,
|
||||
acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({
|
||||
provider: configuredDefault.provider,
|
||||
harnessRuntime: policy.runtime,
|
||||
config: params.config,
|
||||
}),
|
||||
cfg: params.config,
|
||||
agentDir: params.agentDir,
|
||||
includeExternalProfiles: false,
|
||||
});
|
||||
return resolveUsageCredentialType(authLabel) !== "api_key";
|
||||
}
|
||||
|
||||
/** Runs the lightweight security audit used by status JSON/all output. */
|
||||
export async function resolveStatusSecurityAudit(params: {
|
||||
config: OpenClawConfig;
|
||||
@@ -69,11 +131,23 @@ type StatusUsageSummaryOptions = {
|
||||
/** Loads provider usage for status output, defaulting to the config's default agent directory. */
|
||||
export async function resolveStatusUsageSummary(params: StatusUsageSummaryOptions) {
|
||||
const { loadProviderUsageSummary } = await loadProviderUsage();
|
||||
return await loadProviderUsageSummary({
|
||||
const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.config);
|
||||
const usage = await loadProviderUsageSummary({
|
||||
timeoutMs: params.timeoutMs,
|
||||
config: params.config,
|
||||
agentDir: params.agentDir ?? resolveDefaultAgentDir(params.config),
|
||||
agentDir,
|
||||
});
|
||||
if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir })) {
|
||||
return usage;
|
||||
}
|
||||
const codexUsage = await loadProviderUsageSummary({
|
||||
timeoutMs: params.timeoutMs,
|
||||
providers: ["openai"],
|
||||
auth: [buildCodexSyntheticUsageAuth()],
|
||||
config: params.config,
|
||||
agentDir,
|
||||
});
|
||||
return mergeUsageSummaries(usage, codexUsage);
|
||||
}
|
||||
|
||||
/** Exposes the lazily loaded provider-usage module for callers that need its helpers. */
|
||||
|
||||
@@ -131,7 +131,7 @@ type FacadeModule = {
|
||||
getMemorySearchManager: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
purpose?: "default" | "status";
|
||||
purpose?: "default" | "status" | "cli";
|
||||
}) => Promise<{
|
||||
manager: MemorySearchManager | null;
|
||||
error?: string;
|
||||
|
||||
@@ -102,6 +102,21 @@ export type MemoryPluginRuntime = {
|
||||
purpose?: "default" | "status" | "cli";
|
||||
}): Promise<{
|
||||
manager: RegisteredMemorySearchManager | null;
|
||||
debug?: {
|
||||
backend?: "builtin" | "qmd";
|
||||
purpose?: "default" | "status" | "cli";
|
||||
managerMs?: number;
|
||||
managerCacheState?:
|
||||
| "cached-full-hit"
|
||||
| "cached-full-miss"
|
||||
| "transient-cli"
|
||||
| "transient-status"
|
||||
| "pending-create-wait"
|
||||
| "fallback-builtin"
|
||||
| "recent-failure-cooldown";
|
||||
qmdIdentityHash?: string;
|
||||
failureCode?: "qmd-unavailable";
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
resolveMemoryBackendConfig(params: {
|
||||
|
||||
@@ -1902,7 +1902,7 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls through to npm reinstall when metadata probing fails", async () => {
|
||||
it("falls through to npm reinstall when metadata probing fails for valid specs", async () => {
|
||||
const warn = vi.fn();
|
||||
const installPath = createInstalledPackageDir({
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
@@ -1937,6 +1937,107 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records range metadata probing failures without falling through to npm reinstall", async () => {
|
||||
const warn = vi.fn();
|
||||
const installPath = createInstalledPackageDir({
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
version: "0.9.0",
|
||||
});
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "registry timeout",
|
||||
});
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config: createNpmInstallConfig({
|
||||
pluginId: "lossless-claw",
|
||||
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
||||
installPath,
|
||||
}),
|
||||
pluginIds: ["lossless-claw"],
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
pluginId: "lossless-claw",
|
||||
status: "error",
|
||||
message: "Failed to check lossless-claw: npm view failed: registry timeout",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses failure cleanup when metadata probing fails and disableOnFailure is enabled", async () => {
|
||||
const warn = vi.fn();
|
||||
const installPath = createInstalledPackageDir({
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
version: "0.9.0",
|
||||
});
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "registry timeout",
|
||||
});
|
||||
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["lossless-claw", "keep"],
|
||||
deny: ["lossless-claw", "blocked"],
|
||||
slots: {
|
||||
memory: "lossless-claw",
|
||||
contextEngine: "lossless-claw",
|
||||
},
|
||||
entries: {
|
||||
"lossless-claw": {
|
||||
enabled: true,
|
||||
config: { preserved: true },
|
||||
},
|
||||
},
|
||||
installs: {
|
||||
"lossless-claw": {
|
||||
source: "npm",
|
||||
spec: "@martian-engineering/lossless-claw@^0.9.0",
|
||||
installPath,
|
||||
resolvedName: "@martian-engineering/lossless-claw",
|
||||
resolvedVersion: "0.9.0",
|
||||
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginIds: ["lossless-claw"],
|
||||
disableOnFailure: true,
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
const message =
|
||||
'Disabled "lossless-claw" after plugin update failure; OpenClaw will continue without it. Failed to check lossless-claw: npm view failed: registry timeout';
|
||||
expect(warn).toHaveBeenCalledWith(message);
|
||||
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["lossless-claw"]).toEqual({
|
||||
enabled: false,
|
||||
config: { preserved: true },
|
||||
});
|
||||
expect(result.config.plugins?.allow).toEqual(["keep"]);
|
||||
expect(result.config.plugins?.deny).toEqual(["blocked"]);
|
||||
expect(result.config.plugins?.slots).toEqual({
|
||||
memory: "memory-core",
|
||||
contextEngine: "legacy",
|
||||
});
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
pluginId: "lossless-claw",
|
||||
status: "skipped",
|
||||
message,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
source: "npm",
|
||||
@@ -3864,6 +3965,7 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
it("reuses the recorded managed extensions root when updating external plugins", async () => {
|
||||
const installPath = "/var/openclaw/extensions/demo";
|
||||
const extensionsDir = "/var/openclaw/extensions";
|
||||
const expectedExtensionsDir = path.resolve(extensionsDir);
|
||||
installPluginFromNpmSpecMock.mockResolvedValue(
|
||||
createSuccessfulNpmUpdateResult({
|
||||
pluginId: "demo",
|
||||
@@ -3947,7 +4049,6 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
pluginIds: ["demo"],
|
||||
});
|
||||
|
||||
const expectedExtensionsDir = path.resolve(extensionsDir);
|
||||
expect(npmInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
||||
expect(clawHubInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
||||
expect(marketplaceInstallCall()?.extensionsDir).toBe(expectedExtensionsDir);
|
||||
|
||||
@@ -493,7 +493,7 @@ function resolveRecordedExtensionsDir(params: {
|
||||
const parentDir = path.dirname(params.installPath);
|
||||
try {
|
||||
const canonicalInstallPath = resolvePluginInstallDir(params.pluginId, parentDir);
|
||||
return canonicalInstallPath === params.installPath ? parentDir : undefined;
|
||||
return pathsEqual(canonicalInstallPath, params.installPath) ? parentDir : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1584,6 +1584,10 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (!parseRegistryNpmSpec(effectiveSpec!)) {
|
||||
recordFailure(pluginId, `Failed to check ${pluginId}: ${metadataResult.error}`);
|
||||
continue;
|
||||
}
|
||||
logger.warn?.(
|
||||
`Could not check ${pluginId} before update; falling back to installer path: ${metadataResult.error}`,
|
||||
);
|
||||
|
||||
63
src/status/codex-synthetic-usage.ts
Normal file
63
src/status/codex-synthetic-usage.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js";
|
||||
import type { ProviderAuth } from "../infra/provider-usage.auth.js";
|
||||
import type { ProviderUsageSnapshot, UsageSummary } from "../infra/provider-usage.types.js";
|
||||
|
||||
export const CODEX_SYNTHETIC_USAGE_PROVIDER = "openai";
|
||||
export const CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER = "codex";
|
||||
|
||||
export function buildCodexSyntheticUsageAuth(
|
||||
params: {
|
||||
authProfileId?: string;
|
||||
} = {},
|
||||
): ProviderAuth {
|
||||
return {
|
||||
provider: CODEX_SYNTHETIC_USAGE_PROVIDER,
|
||||
token: CODEX_APP_SERVER_AUTH_MARKER,
|
||||
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
|
||||
hookProvider: CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseCodexSyntheticUsageForRuntime(params: {
|
||||
provider?: string;
|
||||
effectiveHarness?: string;
|
||||
}): boolean {
|
||||
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
|
||||
const provider = normalizeOptionalLowercaseString(params.provider);
|
||||
return (
|
||||
harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER &&
|
||||
(provider === CODEX_SYNTHETIC_USAGE_PROVIDER || provider === "codex")
|
||||
);
|
||||
}
|
||||
|
||||
function hasDisplayableUsageSnapshot(snapshot: ProviderUsageSnapshot): boolean {
|
||||
return snapshot.windows.length > 0 || Boolean(snapshot.summary?.trim());
|
||||
}
|
||||
|
||||
function usageSnapshotRank(snapshot: ProviderUsageSnapshot): number {
|
||||
if (hasDisplayableUsageSnapshot(snapshot)) {
|
||||
return 2;
|
||||
}
|
||||
return snapshot.error ? 0 : 1;
|
||||
}
|
||||
|
||||
export function mergeUsageSummaries(
|
||||
base: UsageSummary,
|
||||
extra: UsageSummary | undefined,
|
||||
): UsageSummary {
|
||||
if (!extra || extra.providers.length === 0) {
|
||||
return base;
|
||||
}
|
||||
const providersById = new Map(base.providers.map((provider) => [provider.provider, provider]));
|
||||
for (const provider of extra.providers) {
|
||||
const existing = providersById.get(provider.provider);
|
||||
if (!existing || usageSnapshotRank(provider) >= usageSnapshotRank(existing)) {
|
||||
providersById.set(provider.provider, provider);
|
||||
}
|
||||
}
|
||||
return {
|
||||
updatedAt: base.updatedAt,
|
||||
providers: [...providersById.values()],
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import { resolveContextTokensForModel } from "../agents/context.js";
|
||||
import { resolveFastModeState } from "../agents/fast-mode.js";
|
||||
import { resolveModelAuthLabel } from "../agents/model-auth-label.js";
|
||||
import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js";
|
||||
import {
|
||||
areRuntimeModelRefsEquivalent,
|
||||
shouldPreferActiveRuntimeAliasAuthLabel,
|
||||
@@ -50,6 +49,10 @@ import {
|
||||
formatTaskStatusTitle,
|
||||
} from "../tasks/task-status.js";
|
||||
import { resolveActiveFallbackState } from "./fallback-notice-state.js";
|
||||
import {
|
||||
buildCodexSyntheticUsageAuth,
|
||||
shouldUseCodexSyntheticUsageForRuntime,
|
||||
} from "./codex-synthetic-usage.js";
|
||||
import { formatCompactPluginHealthLine } from "./status-plugin-health.js";
|
||||
import type { BuildStatusTextParams } from "./status-text.types.js";
|
||||
|
||||
@@ -226,15 +229,6 @@ function resolveCodexSyntheticUsageAuthProfileId(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseCodexSyntheticUsage(params: {
|
||||
provider?: string;
|
||||
effectiveHarness?: string;
|
||||
}): boolean {
|
||||
const harness = normalizeOptionalLowercaseString(params.effectiveHarness);
|
||||
const provider = normalizeOptionalLowercaseString(params.provider);
|
||||
return harness === "codex" && (provider === "openai" || provider === "codex");
|
||||
}
|
||||
|
||||
function formatSessionTaskLine(sessionKey: string): string | undefined {
|
||||
const snapshot = buildTaskStatusSnapshot(listTasksForSessionKeyForStatus(sessionKey));
|
||||
const task = snapshot.focus;
|
||||
@@ -453,11 +447,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
const usageProvider = activeRuntimeIsAuthoritative ? activeProvider : selectedLookupProvider;
|
||||
const selectedUsageCredentialType = resolveUsageCredentialType(usageAuthLabel);
|
||||
const useCodexSyntheticUsage =
|
||||
shouldUseCodexSyntheticUsage({
|
||||
selectedUsageCredentialType !== "api_key" &&
|
||||
shouldUseCodexSyntheticUsageForRuntime({
|
||||
provider: usageStatusProvider,
|
||||
effectiveHarness,
|
||||
}) &&
|
||||
(selectedUsageCredentialType === "oauth" || selectedUsageCredentialType === "token");
|
||||
});
|
||||
const codexUsageAuthProfileId = useCodexSyntheticUsage
|
||||
? resolveCodexSyntheticUsageAuthProfileId({
|
||||
profileId: sessionEntry?.authProfileOverride,
|
||||
@@ -491,14 +485,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
workspaceDir: statusWorkspaceDir,
|
||||
config: cfg,
|
||||
auth: useCodexSyntheticUsage
|
||||
? [
|
||||
{
|
||||
provider: "openai",
|
||||
token: CODEX_APP_SERVER_AUTH_MARKER,
|
||||
...(codexUsageAuthProfileId ? { authProfileId: codexUsageAuthProfileId } : {}),
|
||||
hookProvider: "codex",
|
||||
},
|
||||
]
|
||||
? [buildCodexSyntheticUsageAuth({ authProfileId: codexUsageAuthProfileId })]
|
||||
: undefined,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
|
||||
@@ -1303,80 +1303,6 @@
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.content--sessions {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background:
|
||||
radial-gradient(circle at 8% 12%, rgba(255, 0, 128, 0.6), transparent 24%),
|
||||
radial-gradient(circle at 90% 10%, rgba(0, 180, 255, 0.58), transparent 25%),
|
||||
radial-gradient(circle at 82% 88%, rgba(132, 255, 0, 0.48), transparent 26%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
#ff004c 0%,
|
||||
#ff8a00 15%,
|
||||
#ffe600 30%,
|
||||
#00d084 45%,
|
||||
#00a3ff 60%,
|
||||
#6d4cff 75%,
|
||||
#ff4fd8 90%,
|
||||
#ff004c 100%
|
||||
);
|
||||
background-attachment: local;
|
||||
}
|
||||
|
||||
.content--sessions::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: var(--shell-topbar-height) 0 0 var(--shell-nav-width);
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.24) 0 18px, transparent 18px 36px),
|
||||
conic-gradient(
|
||||
from 0.1turn at 52% 48%,
|
||||
rgba(255, 0, 76, 0.42),
|
||||
rgba(255, 138, 0, 0.38),
|
||||
rgba(255, 230, 0, 0.36),
|
||||
rgba(0, 208, 132, 0.38),
|
||||
rgba(0, 163, 255, 0.42),
|
||||
rgba(109, 76, 255, 0.42),
|
||||
rgba(255, 79, 216, 0.44),
|
||||
rgba(255, 0, 76, 0.42)
|
||||
);
|
||||
filter: saturate(1.45);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .content--sessions::before {
|
||||
left: var(--shell-nav-rail-width);
|
||||
}
|
||||
|
||||
.content--sessions::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: var(--shell-topbar-height) 0 0 var(--shell-nav-width);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 8, 16, 0.34), rgba(8, 8, 16, 0.66)),
|
||||
radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.36), transparent 22%);
|
||||
}
|
||||
|
||||
.shell--nav-collapsed .content--sessions::after {
|
||||
left: var(--shell-nav-rail-width);
|
||||
}
|
||||
|
||||
.content--sessions .content-header,
|
||||
.content--sessions .panel,
|
||||
.content--sessions .card,
|
||||
.content--sessions .settings-workspace {
|
||||
backdrop-filter: blur(14px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.35);
|
||||
box-shadow:
|
||||
0 16px 44px rgba(0, 0, 0, 0.26),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -348,16 +348,6 @@ describe("renderApp assistant avatar routing", () => {
|
||||
expect(content?.classList.contains("content--chat")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the sessions route so it can carry the rainbow background treatment", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(renderApp(createState({ tab: "sessions" })), container);
|
||||
|
||||
const content = container.querySelector<HTMLElement>("main.content");
|
||||
expect(content?.classList.contains("content--sessions")).toBe(true);
|
||||
expect(content?.classList.contains("content--chat")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not render chat errors in non-chat page headers", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
|
||||
@@ -2651,9 +2651,8 @@ export function renderApp(state: AppViewState) {
|
||||
<main
|
||||
class="content ${isChat ? "content--chat" : ""} ${state.tab === "logs"
|
||||
? "content--logs"
|
||||
: ""} ${state.tab === "sessions" ? "content--sessions" : ""} ${state.tab === "workboard"
|
||||
? "content--workboard"
|
||||
: ""} ${state.tab === "skillWorkshop"
|
||||
: ""} ${state.tab === "workboard" ? "content--workboard" : ""} ${state.tab ===
|
||||
"skillWorkshop"
|
||||
? `content--skill-workshop ${
|
||||
state.skillWorkshopMode === "today" ? "content--skill-workshop-today" : ""
|
||||
}`
|
||||
|
||||
Reference in New Issue
Block a user