mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 01:01:58 +08:00
Compare commits
1 Commits
main
...
feat/qmd-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3da987afe |
@@ -1,2 +1,2 @@
|
||||
9d5b34975270bb2d16748002c1441ab48fde81af8eb12cc8eb3e341c862232ff plugin-sdk-api-baseline.json
|
||||
f1a6ff189498d955cad6d6fb912eb4cad7aeb628f89c51d0745e146fe0d163d6 plugin-sdk-api-baseline.jsonl
|
||||
ea7c5c6dc96594843238bdc8674e0f03041a61445d6e2d0ab82c30c9ce832f91 plugin-sdk-api-baseline.json
|
||||
65282a8e00237c16745670e2583a289349be1dbd1a0d395789da9dceb1538cf9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -737,10 +737,6 @@ outbound host generic and use the messaging adapter surface for provider rules:
|
||||
should be treated as `direct`, `group`, or `channel` before directory lookup.
|
||||
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
|
||||
input should skip straight to id-like resolution instead of directory search.
|
||||
- `messaging.targetResolver.reservedLiterals` lists bare words that are
|
||||
channel/session references for that provider. Resolution preserves configured
|
||||
directory entries before rejecting reserved literals, then fails closed on a
|
||||
directory miss.
|
||||
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
|
||||
core needs a final provider-owned resolution after normalization or after a
|
||||
directory miss.
|
||||
|
||||
@@ -739,7 +739,7 @@ Write colocated tests in `src/channel.test.ts`:
|
||||
describeMessageTool and action discovery
|
||||
</Card>
|
||||
<Card title="Target resolution" icon="crosshair" href="/plugins/architecture-internals#channel-target-resolution">
|
||||
inferTargetChatType, looksLikeId, reservedLiterals, resolveTarget
|
||||
inferTargetChatType, looksLikeId, resolveTarget
|
||||
</Card>
|
||||
<Card title="Runtime helpers" icon="settings" href="/plugins/sdk-runtime">
|
||||
TTS, STT, media, subagent via api.runtime
|
||||
|
||||
@@ -345,7 +345,7 @@ describe("discordOutbound", () => {
|
||||
2,
|
||||
);
|
||||
expect(messageOptions.accountId).toBe("default");
|
||||
expect(messageOptions.replyTo).toBe("reply-1");
|
||||
expect(messageOptions.replyTo).toBeUndefined();
|
||||
|
||||
const mediaCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1);
|
||||
expect(mediaCall[0]).toBe("channel:123456");
|
||||
@@ -353,7 +353,7 @@ describe("discordOutbound", () => {
|
||||
const mediaOptions = mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1, 2);
|
||||
expect(mediaOptions.accountId).toBe("default");
|
||||
expect(mediaOptions.mediaUrl).toBe("https://example.com/extra.png");
|
||||
expect(mediaOptions.replyTo).toBe("reply-1");
|
||||
expect(mediaOptions.replyTo).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
channel: "discord",
|
||||
messageId: "msg-1",
|
||||
@@ -361,31 +361,6 @@ describe("discordOutbound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps captured replyTo on audioAsVoice sends when replyToMode is batched", async () => {
|
||||
await discordOutbound.sendPayload?.({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "voice note",
|
||||
mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.png"],
|
||||
audioAsVoice: true,
|
||||
},
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
replyToMode: "batched",
|
||||
});
|
||||
|
||||
expect(
|
||||
mockObjectArg(hoisted.sendVoiceMessageDiscordMock, "sendVoiceMessageDiscord", 0, 2).replyTo,
|
||||
).toBe("reply-1");
|
||||
expect(
|
||||
hoisted.sendMessageDiscordMock.mock.calls.map(
|
||||
(call) => (call[2] as { replyTo?: unknown } | undefined)?.replyTo,
|
||||
),
|
||||
).toEqual(["reply-1", "reply-1"]);
|
||||
});
|
||||
|
||||
it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => {
|
||||
await discordOutbound.sendPayload?.({
|
||||
cfg: {},
|
||||
|
||||
@@ -84,15 +84,13 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
const sendContext = await createDiscordPayloadSendContext(ctx);
|
||||
|
||||
if (payload.audioAsVoice && mediaUrls.length > 0) {
|
||||
// audioAsVoice emits one logical Discord reply across voice/text/media sends.
|
||||
// Capture before helper calls consume implicit single-use reply targets.
|
||||
const voiceReplyTo = sendContext.resolveReplyTo();
|
||||
let lastResult = await sendContext.withRetry(
|
||||
async () =>
|
||||
await sendContext.sendVoice(sendContext.target, mediaUrls[0], {
|
||||
...resolveDiscordDeliveryOptions(ctx, sendContext),
|
||||
replyTo: voiceReplyTo,
|
||||
}),
|
||||
await sendContext.sendVoice(
|
||||
sendContext.target,
|
||||
mediaUrls[0],
|
||||
resolveDiscordDeliveryOptions(ctx, sendContext),
|
||||
),
|
||||
);
|
||||
if (payload.text?.trim()) {
|
||||
lastResult = await sendContext.withRetry(
|
||||
@@ -100,7 +98,6 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
await sendContext.send(sendContext.target, payload.text, {
|
||||
verbose: false,
|
||||
...resolveDiscordFormattedDeliveryOptions(ctx, sendContext),
|
||||
replyTo: voiceReplyTo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -110,7 +107,6 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
await sendContext.send(sendContext.target, "", {
|
||||
verbose: false,
|
||||
...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl),
|
||||
replyTo: voiceReplyTo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,35 +55,20 @@ describe("PDF document extractor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts text first and renders each fallback page with its own pixel budget", async () => {
|
||||
pdfDocument.extract
|
||||
.mockResolvedValueOnce({ text: "", images: [] })
|
||||
.mockResolvedValueOnce({
|
||||
text: "",
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
bytes: Uint8Array.from(Buffer.from("png1")),
|
||||
mimeType: "image/png",
|
||||
page: 1,
|
||||
width: 5,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
text: "",
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
bytes: Uint8Array.from(Buffer.from("png2")),
|
||||
mimeType: "image/png",
|
||||
page: 2,
|
||||
width: 5,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
it("extracts text first and renders fallback images through clawpdf", async () => {
|
||||
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({
|
||||
text: "",
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
bytes: Uint8Array.from(Buffer.from("png")),
|
||||
mimeType: "image/png",
|
||||
page: 1,
|
||||
width: 10,
|
||||
height: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const extractor = createPdfDocumentExtractor();
|
||||
|
||||
const result = await extractor.extract(request());
|
||||
@@ -97,24 +82,18 @@ describe("PDF document extractor", () => {
|
||||
maxPages: 2,
|
||||
maxTextChars: 200_000,
|
||||
});
|
||||
// Each page renders in its own extract() call, with the aggregate pixel cap
|
||||
// allocated across selected pages so later pages are not starved.
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
|
||||
mode: "images",
|
||||
pages: [1],
|
||||
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
|
||||
});
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
|
||||
mode: "images",
|
||||
pages: [2],
|
||||
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
|
||||
maxPages: 2,
|
||||
image: {
|
||||
maxDimension: 10_000,
|
||||
maxPixels: 100,
|
||||
forms: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: "",
|
||||
images: [
|
||||
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
|
||||
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
|
||||
],
|
||||
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
|
||||
});
|
||||
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -152,9 +131,8 @@ describe("PDF document extractor", () => {
|
||||
expect(pdfDocument.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters selected pages and renders them one page per image call", async () => {
|
||||
it("filters selected pages before passing them to clawpdf", async () => {
|
||||
pdfDocument.extract
|
||||
.mockResolvedValueOnce({ text: "", images: [] })
|
||||
.mockResolvedValueOnce({ text: "", images: [] })
|
||||
.mockResolvedValueOnce({ text: "", images: [] });
|
||||
const extractor = createPdfDocumentExtractor();
|
||||
@@ -163,15 +141,11 @@ describe("PDF document extractor", () => {
|
||||
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ mode: "text", pages: [2, 1] }),
|
||||
expect.objectContaining({ pages: [2, 1] }),
|
||||
);
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ mode: "images", pages: [2] }),
|
||||
);
|
||||
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ mode: "images", pages: [1] }),
|
||||
expect.objectContaining({ pages: [2, 1] }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -83,38 +83,17 @@ async function extractPdfContent(
|
||||
return { text, images: [] };
|
||||
}
|
||||
|
||||
// clawpdf's image render budget (maxPixels) is shared across every page in one
|
||||
// extract() call: the first page consumes it and later pages collapse to 1x1
|
||||
// PNGs that vision models reject. Render each page separately, allocating the
|
||||
// remaining aggregate budget across pages that still need rendering.
|
||||
const imagePages =
|
||||
pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1);
|
||||
|
||||
try {
|
||||
const images: DocumentExtractedImage[] = [];
|
||||
let remainingPixels = request.maxPixels;
|
||||
for (let index = 0; index < imagePages.length; index += 1) {
|
||||
if (remainingPixels <= 0) {
|
||||
break;
|
||||
}
|
||||
const pagesRemaining = imagePages.length - index;
|
||||
const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining));
|
||||
const pageNumber = imagePages[index];
|
||||
const imageResult = await pdf.extract({
|
||||
mode: "images",
|
||||
pages: [pageNumber],
|
||||
image: {
|
||||
maxDimension: MAX_RENDER_DIMENSION,
|
||||
maxPixels: maxPixelsPerPage,
|
||||
forms: true,
|
||||
},
|
||||
});
|
||||
for (const image of imageResult.images) {
|
||||
images.push(toDocumentImage(image));
|
||||
remainingPixels -= image.width * image.height;
|
||||
}
|
||||
}
|
||||
return { text, images };
|
||||
const imageResult = await pdf.extract({
|
||||
mode: "images",
|
||||
...pageSelection,
|
||||
image: {
|
||||
maxDimension: MAX_RENDER_DIMENSION,
|
||||
maxPixels: request.maxPixels,
|
||||
forms: true,
|
||||
},
|
||||
});
|
||||
return { text, images: imageResult.images.map(toDocumentImage) };
|
||||
} catch (err) {
|
||||
request.onImageExtractionError?.(err);
|
||||
return { text, images: [] };
|
||||
|
||||
@@ -49,15 +49,6 @@ describe("sanitizeOutboundText", () => {
|
||||
expect(result).not.toMatch(/^assistant:$/m);
|
||||
});
|
||||
|
||||
it("preserves prose lines that merely end with 'user:'/'system:'", () => {
|
||||
expect(sanitizeOutboundText("Please send this reply to the user:")).toBe(
|
||||
"Please send this reply to the user:",
|
||||
);
|
||||
expect(sanitizeOutboundText("Here is a note for the system:")).toBe(
|
||||
"Here is a note for the system:",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses excessive blank lines after stripping", () => {
|
||||
const text = "Hello\n\n\n\n\nWorld";
|
||||
expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld");
|
||||
|
||||
@@ -7,9 +7,7 @@ import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-chun
|
||||
*/
|
||||
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
|
||||
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
|
||||
// Only a standalone role marker on its own line (a leaked turn boundary) — not
|
||||
// any line that merely ends with the word "user/system/assistant:" in prose.
|
||||
const ROLE_TURN_MARKER_RE = /^[ \t]*(?:user|system|assistant)\s*:\s*$/gm;
|
||||
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
|
||||
|
||||
/**
|
||||
* Strip all assistant-internal scaffolding from outbound text before delivery.
|
||||
|
||||
@@ -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,246 @@ 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();
|
||||
});
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.lstat(targetPath);
|
||||
@@ -406,6 +660,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,15 @@ import {
|
||||
type QmdSessionArtifactMapping,
|
||||
} from "../qmd-session-artifacts.js";
|
||||
import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js";
|
||||
import {
|
||||
readQmdCollectionValidationCache,
|
||||
readQmdMultiCollectionProbeCache,
|
||||
writeQmdCollectionValidationCache,
|
||||
writeQmdMultiCollectionProbeCache,
|
||||
type QmdRuntimeCollectionValidationCacheContext,
|
||||
type QmdRuntimeManagedCollection,
|
||||
type QmdRuntimeMultiCollectionProbeCacheContext,
|
||||
} from "./qmd-runtime-cache.js";
|
||||
import {
|
||||
countChokidarWatchedEntries,
|
||||
type MemoryWatchPressureWarningState,
|
||||
@@ -324,6 +333,14 @@ 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 QmdManagerMode = "full" | "status" | "cli";
|
||||
type QmdManagerRuntimeConfig = {
|
||||
workspaceDir: string;
|
||||
@@ -453,6 +470,9 @@ 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 currentSearchMultiCollectionProbeDebug: QmdMultiCollectionProbeDebug | undefined;
|
||||
private currentSearchPlanDebug: QmdSearchPlanDebug | undefined;
|
||||
|
||||
private constructor(params: {
|
||||
agentId: string;
|
||||
@@ -612,11 +632,118 @@ 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 buildQmdCollectionValidationCacheContext(): QmdRuntimeCollectionValidationCacheContext {
|
||||
return {
|
||||
workspaceDir: this.workspaceDir,
|
||||
agentId: this.agentId,
|
||||
qmdCommand: this.qmd.command,
|
||||
qmdIndexPath: this.indexPath,
|
||||
searchMode: this.qmd.searchMode,
|
||||
collections: this.qmdRuntimeCacheCollections(),
|
||||
sources: this.qmdRuntimeCacheSources(),
|
||||
};
|
||||
}
|
||||
|
||||
private buildQmdMultiCollectionProbeCacheContext(): QmdRuntimeMultiCollectionProbeCacheContext {
|
||||
return {
|
||||
workspaceDir: this.workspaceDir,
|
||||
agentId: this.agentId,
|
||||
qmdCommand: this.qmd.command,
|
||||
qmdIndexPath: this.indexPath,
|
||||
searchMode: this.qmd.searchMode,
|
||||
sources: this.qmdRuntimeCacheSources(),
|
||||
};
|
||||
}
|
||||
|
||||
private recordSearchPlanDebug(params: {
|
||||
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)),
|
||||
);
|
||||
this.currentSearchPlanDebug = {
|
||||
command: params.command,
|
||||
collectionCount: params.collectionNames.length,
|
||||
groupCount: params.collectionGroups.length,
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
private resetQmdSearchRuntimeDebug(): void {
|
||||
this.currentSearchMultiCollectionProbeDebug = undefined;
|
||||
this.currentSearchPlanDebug = undefined;
|
||||
}
|
||||
|
||||
private consumeQmdRuntimeDebug(): MemorySearchRuntimeDebug["qmd"] | undefined {
|
||||
const debug: NonNullable<MemorySearchRuntimeDebug["qmd"]> = {};
|
||||
if (this.pendingCollectionValidationDebug) {
|
||||
debug.collectionValidation = this.pendingCollectionValidationDebug;
|
||||
}
|
||||
if (this.currentSearchMultiCollectionProbeDebug) {
|
||||
debug.multiCollectionProbe = this.currentSearchMultiCollectionProbeDebug;
|
||||
}
|
||||
if (this.currentSearchPlanDebug) {
|
||||
debug.searchPlan = this.currentSearchPlanDebug;
|
||||
}
|
||||
this.pendingCollectionValidationDebug = undefined;
|
||||
this.currentSearchMultiCollectionProbeDebug = undefined;
|
||||
this.currentSearchPlanDebug = undefined;
|
||||
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 }): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const cacheContext = this.buildQmdCollectionValidationCacheContext();
|
||||
if (!options?.force) {
|
||||
const cached = await readQmdCollectionValidationCache(cacheContext);
|
||||
if (cached.state === "hit") {
|
||||
await this.ensureCollectionPathsBestEffort();
|
||||
this.pendingCollectionValidationDebug = {
|
||||
cacheState: "hit",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
collectionCount: cached.value.validation.collectionCount,
|
||||
listCalls: 0,
|
||||
showCalls: 0,
|
||||
};
|
||||
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 +758,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 +789,31 @@ 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;
|
||||
this.pendingCollectionValidationDebug = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private async tryRebindSameNameCollection(params: {
|
||||
@@ -713,9 +859,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 +889,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,
|
||||
});
|
||||
@@ -963,7 +1118,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
log.warn(
|
||||
"qmd search failed because a managed collection is missing; repairing collections and retrying once",
|
||||
);
|
||||
await this.ensureCollections();
|
||||
await this.ensureCollections({ force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1318,6 +1473,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (searchSignal?.aborted) {
|
||||
throw asAbortError(searchSignal);
|
||||
}
|
||||
this.resetQmdSearchRuntimeDebug();
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
@@ -1403,6 +1559,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
collectionNames,
|
||||
searchSignal,
|
||||
);
|
||||
this.recordSearchPlanDebug({
|
||||
command: qmdSearchCommand,
|
||||
collectionNames,
|
||||
collectionGroups,
|
||||
});
|
||||
if (collectionGroups.length > 1) {
|
||||
return await this.runQueryAcrossCollectionGroups(
|
||||
trimmed,
|
||||
@@ -1434,6 +1595,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
collectionNames,
|
||||
searchSignal,
|
||||
);
|
||||
this.recordSearchPlanDebug({
|
||||
command: "query",
|
||||
collectionNames,
|
||||
collectionGroups,
|
||||
});
|
||||
if (collectionGroups.length > 1) {
|
||||
return await this.runQueryAcrossCollectionGroups(
|
||||
trimmed,
|
||||
@@ -1512,6 +1678,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
configuredMode: qmdSearchCommand,
|
||||
effectiveMode: effectiveSearchMode,
|
||||
fallback: searchFallbackReason,
|
||||
qmd: this.consumeQmdRuntimeDebug(),
|
||||
});
|
||||
let ranked = results;
|
||||
if (opts?.sources?.length) {
|
||||
@@ -3387,6 +3554,18 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (this.multiCollectionFilterSupported !== null) {
|
||||
return this.multiCollectionFilterSupported;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const cacheContext = this.buildQmdMultiCollectionProbeCacheContext();
|
||||
const cached = await readQmdMultiCollectionProbeCache(cacheContext);
|
||||
if (cached.state === "hit") {
|
||||
this.multiCollectionFilterSupported = cached.value.multiCollectionProbe.supported;
|
||||
this.currentSearchMultiCollectionProbeDebug = {
|
||||
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,12 +3574,26 @@ 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,
|
||||
);
|
||||
this.currentSearchMultiCollectionProbeDebug = {
|
||||
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;
|
||||
this.currentSearchMultiCollectionProbeDebug = {
|
||||
cacheState: "error",
|
||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||
supported: false,
|
||||
};
|
||||
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
|
||||
}
|
||||
return this.multiCollectionFilterSupported;
|
||||
|
||||
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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -422,6 +422,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 +478,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 +499,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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -415,6 +415,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 +472,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 +514,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 +532,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);
|
||||
}
|
||||
@@ -581,6 +593,7 @@ export function createMemorySearchTool(options: {
|
||||
fallback = status.fallback;
|
||||
const latestDebug = runtimeDebug.at(-1);
|
||||
searchMode = latestDebug?.effectiveMode;
|
||||
const searchMs = Math.max(0, Date.now() - searchStartedAt);
|
||||
searchDebug = {
|
||||
backend: status.backend,
|
||||
configuredMode: latestDebug?.configuredMode,
|
||||
@@ -589,7 +602,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: latestDebug?.qmd,
|
||||
hits: rawResults.length,
|
||||
};
|
||||
});
|
||||
@@ -620,6 +636,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,
|
||||
|
||||
@@ -833,7 +833,6 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeTelegramTargetId,
|
||||
hint: "<chatId>",
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
},
|
||||
},
|
||||
resolver: {
|
||||
|
||||
@@ -807,16 +807,16 @@ describe("createTelegramDraftStream", () => {
|
||||
expectNthPreviewSend(api, 2, "foo bar baz qux");
|
||||
});
|
||||
|
||||
it("clamps a first oversized non-final preview on a UTF-16 boundary", async () => {
|
||||
it("clamps a first oversized non-final preview", async () => {
|
||||
const api = createMockDraftApi();
|
||||
const stream = createDraftStream(api, { maxChars: 10 });
|
||||
|
||||
stream.update("123456789😀tail");
|
||||
stream.update("1234567890ABCDEFGHIJ");
|
||||
await stream.flush();
|
||||
|
||||
expect(api.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expectNthPreviewSend(api, 1, "123456789");
|
||||
expect(stream.lastDeliveredText?.()).toBe("123456789");
|
||||
expectNthPreviewSend(api, 1, "1234567890");
|
||||
expect(stream.lastDeliveredText?.()).toBe("1234567890");
|
||||
});
|
||||
|
||||
it("finalizes overflow that was hidden by a clamped non-final preview", async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
takeMessageIdAfterStop,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js";
|
||||
import { renderTelegramHtmlText, telegramHtmlToPlainTextFallback } from "./format.js";
|
||||
import {
|
||||
@@ -170,7 +169,7 @@ function findTelegramDraftChunkLength(
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return sliceUtf16Safe(text, 0, best).length;
|
||||
return best;
|
||||
}
|
||||
|
||||
export function createTelegramDraftStream(params: {
|
||||
|
||||
@@ -16,18 +16,6 @@ if [[ ! -f "$FILTER_FILES" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
|
||||
if [[ -n "$GIT_DIR" ]] && \
|
||||
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
|
||||
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
|
||||
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
|
||||
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
|
||||
[[ -d "$GIT_DIR/rebase-merge" ]] || \
|
||||
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
|
||||
# Sequencer commits stage the operation result, not just the user's local edits.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
|
||||
# Robustness: NUL-delimited file list handles spaces/newlines safely.
|
||||
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.
|
||||
|
||||
@@ -34,19 +34,6 @@ describe("acp session manager", () => {
|
||||
expect(store.getSessionByRunId("run-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes stale run lookup entries when rebinding an active run", () => {
|
||||
const session = store.createSession({
|
||||
sessionKey: "acp:rebind",
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
store.setActiveRun(session.sessionId, "run-old", new AbortController());
|
||||
store.setActiveRun(session.sessionId, "run-new", new AbortController());
|
||||
|
||||
expect(store.getSessionByRunId("run-old")).toBeUndefined();
|
||||
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
|
||||
});
|
||||
|
||||
it("deletes sessions and aborts active runs on close", () => {
|
||||
const session = store.createSession({
|
||||
sessionId: "close-me",
|
||||
|
||||
@@ -150,9 +150,6 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {})
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
if (session.activeRunId && session.activeRunId !== runId) {
|
||||
runIdToSessionId.delete(session.activeRunId);
|
||||
}
|
||||
session.activeRunId = runId;
|
||||
session.abortController = abortController;
|
||||
runIdToSessionId.set(runId, sessionId);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Agent Core tests cover prompt template argument parsing behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
|
||||
|
||||
describe("prompt template arguments", () => {
|
||||
it("preserves quoted empty arguments so positional placeholders stay aligned", () => {
|
||||
expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]);
|
||||
expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]);
|
||||
expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third");
|
||||
});
|
||||
});
|
||||
@@ -5,31 +5,26 @@ export function parseCommandArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
let inQuote: string | null = null;
|
||||
let hasToken = false;
|
||||
|
||||
for (const char of argsString) {
|
||||
if (inQuote) {
|
||||
if (char === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
hasToken = true;
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
hasToken = true;
|
||||
inQuote = char;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (hasToken) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
hasToken = false;
|
||||
}
|
||||
} else {
|
||||
hasToken = true;
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (hasToken) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
return args;
|
||||
|
||||
@@ -55,27 +55,4 @@ describe("media-generation catalog", () => {
|
||||
}),
|
||||
).toEqual(["video-default", "video-pro"]);
|
||||
});
|
||||
|
||||
it("marks a trimmed default model as the catalog default", () => {
|
||||
expect(
|
||||
synthesizeMediaGenerationCatalogEntries({
|
||||
kind: "video_generation",
|
||||
provider: {
|
||||
id: "example",
|
||||
defaultModel: " video-default ",
|
||||
models: ["video-default"],
|
||||
capabilities: {},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "video_generation",
|
||||
provider: "example",
|
||||
model: "video-default",
|
||||
source: "static",
|
||||
default: true,
|
||||
capabilities: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,6 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
|
||||
provider: MediaGenerationCatalogProvider<TCapabilities>;
|
||||
modes?: readonly string[];
|
||||
}): Array<MediaGenerationCatalogEntry<TCapabilities>> {
|
||||
const defaultModel = uniqueTrimmedStrings([params.provider.defaultModel])[0];
|
||||
return uniqueModels(params.provider).map((model) => {
|
||||
const entry: MediaGenerationCatalogEntry<TCapabilities> = {
|
||||
kind: params.kind,
|
||||
@@ -63,7 +62,7 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: {
|
||||
if (params.provider.label) {
|
||||
entry.label = params.provider.label;
|
||||
}
|
||||
if (model === defaultModel) {
|
||||
if (model === params.provider.defaultModel) {
|
||||
entry.default = true;
|
||||
}
|
||||
if (params.modes) {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// Media Understanding Common tests cover provider output extraction behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractGeminiResponse } from "./output-extract.js";
|
||||
|
||||
describe("extractGeminiResponse", () => {
|
||||
it("extracts the response from noisy output with nested JSON objects", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
[
|
||||
"debug: invoking gemini",
|
||||
JSON.stringify({
|
||||
response: "a useful description",
|
||||
usage: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 4,
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("a useful description");
|
||||
});
|
||||
|
||||
it("returns null for an incomplete JSON object", () => {
|
||||
expect(extractGeminiResponse("{")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores unmatched quotes in noisy output before the JSON object", () => {
|
||||
expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores braces inside quoted noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores shell-quoted JSON-like noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat apostrophes inside noisy words as quote delimiters", () => {
|
||||
expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("resynchronizes after an unmatched brace in noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("preserves brace-heavy response text", () => {
|
||||
const response = "{".repeat(33);
|
||||
expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response);
|
||||
});
|
||||
|
||||
it("extracts pretty-printed JSON output", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
JSON.stringify(
|
||||
{
|
||||
response: "pretty response",
|
||||
usage: { inputTokens: 12 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
).toBe("pretty response");
|
||||
});
|
||||
|
||||
it("preserves pretty-printed object elements inside arrays", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
JSON.stringify(
|
||||
{
|
||||
response: "array response",
|
||||
items: [{ id: 1 }, { id: 2 }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
).toBe("array response");
|
||||
});
|
||||
|
||||
it("does not accept an inner response from a malformed trailing object", () => {
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe(
|
||||
"good",
|
||||
);
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good");
|
||||
});
|
||||
|
||||
it("ignores a nested response inside an unfinished outer object", () => {
|
||||
expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not promote a child from a malformed outer object", () => {
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good");
|
||||
expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull();
|
||||
expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe(
|
||||
"good",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,119 +3,16 @@
|
||||
/** Parse the last JSON object in a noisy provider output string. */
|
||||
function extractLastJsonObject(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
const ranges: Array<{ end: number; start: number }> = [];
|
||||
const starts: number[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let preambleQuote: string | undefined;
|
||||
let preambleEscaped = false;
|
||||
let previousSignificant: string | undefined;
|
||||
let lineHasNonWhitespace = false;
|
||||
let arrayDepth = 0;
|
||||
let candidateHasContent = false;
|
||||
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const character = trimmed[index];
|
||||
if (inString) {
|
||||
if (character === "\n" || character === "\r") {
|
||||
starts.length = 0;
|
||||
inString = false;
|
||||
escaped = false;
|
||||
} else if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts.length === 0) {
|
||||
if (preambleQuote !== undefined) {
|
||||
if (character === "\n" || character === "\r") {
|
||||
preambleQuote = undefined;
|
||||
preambleEscaped = false;
|
||||
} else if (preambleEscaped) {
|
||||
preambleEscaped = false;
|
||||
} else if (character === "\\") {
|
||||
preambleEscaped = true;
|
||||
} else if (character === preambleQuote) {
|
||||
preambleQuote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
const previous = trimmed[index - 1];
|
||||
if (previous === undefined || /[\s:([{]/.test(previous)) {
|
||||
preambleQuote = character;
|
||||
preambleEscaped = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (character === "{") {
|
||||
arrayDepth = 0;
|
||||
candidateHasContent = false;
|
||||
starts.push(index);
|
||||
}
|
||||
if (!/\s/.test(character)) {
|
||||
previousSignificant = character;
|
||||
lineHasNonWhitespace = true;
|
||||
} else if (character === "\n" || character === "\r") {
|
||||
lineHasNonWhitespace = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const hadCandidateContent = candidateHasContent;
|
||||
if (character === '"') {
|
||||
inString = true;
|
||||
} else if (character === "{") {
|
||||
if (
|
||||
previousSignificant === ":" ||
|
||||
previousSignificant === "[" ||
|
||||
previousSignificant === '"' ||
|
||||
(previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0))
|
||||
) {
|
||||
starts.push(index);
|
||||
} else if (!lineHasNonWhitespace && !hadCandidateContent) {
|
||||
// Only resync at a clean record boundary; otherwise keep malformed
|
||||
// outer objects from promoting diagnostic payloads as valid results.
|
||||
starts.length = 1;
|
||||
starts[0] = index;
|
||||
arrayDepth = 0;
|
||||
candidateHasContent = false;
|
||||
}
|
||||
} else if (character === "}" && starts.length > 0) {
|
||||
const start = starts.pop();
|
||||
if (start !== undefined && starts.length === 0) {
|
||||
ranges.push({ start, end: index });
|
||||
}
|
||||
} else if (character === "[") {
|
||||
arrayDepth += 1;
|
||||
} else if (character === "]" && arrayDepth > 0) {
|
||||
arrayDepth -= 1;
|
||||
}
|
||||
|
||||
if (!/\s/.test(character)) {
|
||||
candidateHasContent = true;
|
||||
previousSignificant = character;
|
||||
lineHasNonWhitespace = true;
|
||||
} else if (character === "\n" || character === "\r") {
|
||||
lineHasNonWhitespace = false;
|
||||
}
|
||||
const start = trimmed.lastIndexOf("{");
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = ranges.length - 1; index >= 0; index -= 1) {
|
||||
const range = ranges[index];
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(range.start, range.end + 1));
|
||||
} catch {
|
||||
// Ignore malformed objects and try the previous completed range.
|
||||
}
|
||||
const slice = trimmed.slice(start);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract Gemini CLI-style response text from the last JSON object in output. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -25,35 +25,10 @@ PROBE_ATTEMPT_TIMEOUT_MS="$(
|
||||
PROBE_MAX_BODY_BYTES="$(
|
||||
openclaw_e2e_read_positive_int_env OPENCLAW_UPGRADE_SURVIVOR_PROBE_MAX_BODY_BYTES 1048576
|
||||
)"
|
||||
ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}"
|
||||
|
||||
resolve_lane_artifact_suffix() {
|
||||
if [ -n "${OPENCLAW_DOCKER_ALL_LANE_NAME:-}" ]; then
|
||||
printf "%s" "$OPENCLAW_DOCKER_ALL_LANE_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$ROOT_MANAGED_VPS" = "1" ]; then
|
||||
printf "root-managed-vps-upgrade"
|
||||
elif [ "$UPDATE_RESTART_MODE" = "auto-auth" ]; then
|
||||
printf "update-restart-auth"
|
||||
elif [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
|
||||
printf "published-upgrade-survivor"
|
||||
else
|
||||
printf "upgrade-survivor"
|
||||
fi
|
||||
|
||||
if [ -n "${BASELINE_SPEC// }" ]; then
|
||||
printf -- "-%s" "$BASELINE_SPEC"
|
||||
fi
|
||||
if [ "$SCENARIO" != "base" ]; then
|
||||
printf -- "-%s" "$SCENARIO"
|
||||
fi
|
||||
}
|
||||
|
||||
LANE_ARTIFACT_SUFFIX="$(resolve_lane_artifact_suffix)"
|
||||
LANE_ARTIFACT_SUFFIX="${OPENCLAW_DOCKER_ALL_LANE_NAME:-default}"
|
||||
LANE_ARTIFACT_SUFFIX="${LANE_ARTIFACT_SUFFIX//[^A-Za-z0-9_.-]/_}"
|
||||
ARTIFACT_DIR="${OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/upgrade-survivor/$LANE_ARTIFACT_SUFFIX}"
|
||||
ROOT_MANAGED_VPS="${OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS:-0}"
|
||||
DOCKER_RUN_USER_ARGS=()
|
||||
PROBE_ENV_ARGS=(
|
||||
-e OPENCLAW_UPGRADE_SURVIVOR_PROBE_TIMEOUT_MS="$PROBE_TIMEOUT_MS"
|
||||
|
||||
@@ -210,7 +210,7 @@ try {
|
||||
),
|
||||
publicWildcardReexports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
|
||||
214,
|
||||
215,
|
||||
),
|
||||
};
|
||||
publicDeprecatedExportsByEntrypointBudget = readEntrypointBudgetEnv(
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../../infra/tmp-openclaw-dir.js";
|
||||
import { captureEnv, setTestEnvValue } from "../../../test-utils/env.js";
|
||||
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
|
||||
import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js";
|
||||
import {
|
||||
@@ -421,8 +420,7 @@ describe("loadImageFromRef", () => {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(inboundDir, { recursive: true });
|
||||
await fs.writeFile(path.join(inboundDir, mediaId), Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
const image = await loadImageFromRef(
|
||||
@@ -439,7 +437,7 @@ describe("loadImageFromRef", () => {
|
||||
expect(image?.mimeType).toBe("image/png");
|
||||
expect(image?.data).toBe(TINY_PNG_BASE64);
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -672,8 +670,7 @@ describe("detectAndLoadPromptImages", () => {
|
||||
const imagePath = path.join(inboundDir, "signal-replay.png");
|
||||
const pngB64 = TINY_PNG_BASE64;
|
||||
await fs.writeFile(imagePath, Buffer.from(pngB64, "base64"));
|
||||
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
try {
|
||||
const result = await detectAndLoadPromptImages({
|
||||
@@ -688,7 +685,7 @@ describe("detectAndLoadPromptImages", () => {
|
||||
expect(result.skippedCount).toBe(0);
|
||||
expect(result.images).toHaveLength(1);
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
resetGlobalHookRunner,
|
||||
} from "../plugins/hook-runner-global.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
|
||||
|
||||
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
|
||||
@@ -118,9 +117,9 @@ afterEach(() => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
|
||||
}
|
||||
if (originalConfigPath === undefined) {
|
||||
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
} else {
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", originalConfigPath);
|
||||
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
|
||||
}
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { force: true, recursive: true });
|
||||
@@ -260,10 +259,9 @@ describe("tool_result_persist hook", () => {
|
||||
it("keeps sensitive parent keys when custom value patterns match the key probe", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-redact-config-"));
|
||||
tempDirs.push(tempDir);
|
||||
const configPath = path.join(tempDir, "openclaw.json");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
process.env.OPENCLAW_CONFIG_PATH = path.join(tempDir, "openclaw.json");
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
process.env.OPENCLAW_CONFIG_PATH,
|
||||
JSON.stringify({ logging: { redactPatterns: ["/[a-z0-9]{30,}/g"] } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
maybeWrapCommandWithShellSnapshot,
|
||||
resetShellSnapshotCacheForTests,
|
||||
@@ -40,9 +40,9 @@ function setSnapshotStateForTest(
|
||||
options: { home?: string; zdotdir?: string } = {},
|
||||
): void {
|
||||
// Snapshot tests mutate trusted process env, not per-command untrusted env.
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
if (options.home) {
|
||||
setTestEnvValue("HOME", options.home);
|
||||
process.env.HOME = options.home;
|
||||
}
|
||||
if (options.zdotdir) {
|
||||
process.env.ZDOTDIR = options.zdotdir;
|
||||
@@ -91,7 +91,7 @@ describe("exec shell snapshots", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-home-"));
|
||||
tempDirs.push(stateDir, home);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
setTestEnvValue(EXEC_SHELL_SNAPSHOT_ENV, "0");
|
||||
process.env[EXEC_SHELL_SNAPSHOT_ENV] = "0";
|
||||
const command = "echo unchanged";
|
||||
const wrapped = await maybeWrapCommandWithShellSnapshot({
|
||||
command,
|
||||
|
||||
@@ -5,7 +5,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { callGateway as gatewayCall } from "../../gateway/call.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
||||
|
||||
type CallGatewayRequest = Parameters<typeof gatewayCall>[0];
|
||||
|
||||
@@ -19,7 +18,7 @@ function useLoggingConfig(name: string, logging: Record<string, unknown>): void
|
||||
}
|
||||
const configPath = path.join(tempDir, name);
|
||||
fs.writeFileSync(configPath, `${JSON.stringify({ logging })}\n`, "utf8");
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
}
|
||||
|
||||
function createHistoryToolWithMessage(content: string) {
|
||||
@@ -51,9 +50,9 @@ describe("sessions_history redaction", () => {
|
||||
|
||||
afterAll(() => {
|
||||
if (previousConfigPath === undefined) {
|
||||
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
} else {
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", previousConfigPath);
|
||||
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
|
||||
}
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -2,7 +2,6 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
const spawnSyncMock = vi.hoisted(() => vi.fn());
|
||||
@@ -22,7 +21,7 @@ let tempAgentDir: string | undefined;
|
||||
beforeEach(() => {
|
||||
originalAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
tempAgentDir = mkdtempSync(join(tmpdir(), "openclaw-tools-manager-"));
|
||||
setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir);
|
||||
process.env.OPENCLAW_AGENT_DIR = tempAgentDir;
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
spawnSyncMock.mockReturnValue({
|
||||
error: new Error("ENOENT"),
|
||||
@@ -36,9 +35,9 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalAgentDir === undefined) {
|
||||
deleteTestEnvValue("OPENCLAW_AGENT_DIR");
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
setTestEnvValue("OPENCLAW_AGENT_DIR", originalAgentDir);
|
||||
process.env.OPENCLAW_AGENT_DIR = originalAgentDir;
|
||||
}
|
||||
if (tempAgentDir) {
|
||||
rmSync(tempAgentDir, { recursive: true, force: true });
|
||||
|
||||
@@ -78,17 +78,12 @@ export function resolveSelectedAndActiveModel(params: {
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
sessionEntry?: Pick<SessionEntry, "modelProvider" | "model">;
|
||||
parseSelectedProvider?: boolean;
|
||||
}): {
|
||||
selected: ModelRef;
|
||||
active: ModelRef;
|
||||
activeDiffers: boolean;
|
||||
} {
|
||||
const selected = normalizeModelRef(
|
||||
params.selectedModel,
|
||||
params.selectedProvider,
|
||||
params.parseSelectedProvider,
|
||||
);
|
||||
const selected = normalizeModelRef(params.selectedModel, params.selectedProvider);
|
||||
const runtimeModel = normalizeOptionalString(params.sessionEntry?.model);
|
||||
const runtimeProvider = normalizeOptionalString(params.sessionEntry?.modelProvider);
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
import type { ConfigSnapshotMock } from "./commands.test-harness.js";
|
||||
@@ -257,15 +256,15 @@ async function withTempConfigPath<T>(
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-allowlist-config-"));
|
||||
const configPath = path.join(dir, "openclaw.json");
|
||||
const previous = process.env.OPENCLAW_CONFIG_PATH;
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8");
|
||||
try {
|
||||
return await run(configPath);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
} else {
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", previous);
|
||||
process.env.OPENCLAW_CONFIG_PATH = previous;
|
||||
}
|
||||
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
}
|
||||
|
||||
@@ -1249,155 +1249,6 @@ describe("buildStatusReply subagent summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses active fallback provider usage for legacy fallback notices", async () => {
|
||||
const fallbackModel: ModelDefinitionConfig = {
|
||||
id: "MiniMax-M2.7",
|
||||
name: "MiniMax M2.7",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 32_000,
|
||||
};
|
||||
const selectedModel: ModelDefinitionConfig = {
|
||||
id: "mimo-v2-flash",
|
||||
name: "MiMo V2 Flash",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 32_000,
|
||||
};
|
||||
providerUsageMock.loadProviderUsageSummary.mockImplementation(async (options) => ({
|
||||
updatedAt: Date.now(),
|
||||
providers:
|
||||
options?.providers?.includes("minimax") === true
|
||||
? [
|
||||
{
|
||||
provider: "minimax",
|
||||
displayName: "MiniMax",
|
||||
windows: [{ label: "day", usedPercent: 20 }],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}));
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
models: {
|
||||
providers: {
|
||||
"minimax-portal": {
|
||||
baseUrl: "https://api.minimax.test/v1",
|
||||
models: [fallbackModel],
|
||||
},
|
||||
xiaomi: {
|
||||
baseUrl: "https://api.xiaomi.test/v1",
|
||||
models: [selectedModel],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-legacy-fallback-usage",
|
||||
updatedAt: 0,
|
||||
providerOverride: "xiaomi",
|
||||
modelOverride: "mimo-v2-flash",
|
||||
modelProvider: "minimax-portal",
|
||||
model: "MiniMax-M2.7",
|
||||
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
|
||||
fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7",
|
||||
fallbackNoticeReason: "model not allowed",
|
||||
totalTokens: 49_000,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 1_048_576,
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "xiaomi",
|
||||
model: "mimo-v2-flash",
|
||||
contextTokens: 1_048_576,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "api-key",
|
||||
activeModelAuthOverride: "api-key",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Fallback: minimax-portal/MiniMax-M2.7");
|
||||
expect(normalized).toContain("Context: 49k/200k");
|
||||
expect(normalized).toContain("Usage: day 80% left");
|
||||
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["minimax"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses live runtime context for unresolved active fallback notices", async () => {
|
||||
const selectedModel: ModelDefinitionConfig = {
|
||||
id: "mimo-v2-flash",
|
||||
name: "MiMo V2 Flash",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 32_000,
|
||||
};
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
models: {
|
||||
providers: {
|
||||
xiaomi: {
|
||||
baseUrl: "https://api.xiaomi.test/v1",
|
||||
models: [selectedModel],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-unresolved-fallback-context",
|
||||
updatedAt: 0,
|
||||
providerOverride: "xiaomi",
|
||||
modelOverride: "mimo-v2-flash",
|
||||
modelProvider: "custom-runtime",
|
||||
model: "unknown-fallback-model",
|
||||
fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash",
|
||||
fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model",
|
||||
fallbackNoticeReason: "model not allowed",
|
||||
totalTokens: 49_000,
|
||||
totalTokensFresh: true,
|
||||
contextTokens: 1_048_576,
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "mobilechat",
|
||||
provider: "xiaomi",
|
||||
model: "mimo-v2-flash",
|
||||
contextTokens: 123_456,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "api-key",
|
||||
activeModelAuthOverride: "api-key",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Fallback: custom-runtime/unknown-fallback-model");
|
||||
expect(normalized).toContain("Context: 49k/123k");
|
||||
expect(normalized).not.toContain("Context: 49k/1.0m");
|
||||
});
|
||||
|
||||
it("shows DeepSeek balance summaries in /status output", async () => {
|
||||
providerUsageMock.loadProviderUsageSummary.mockResolvedValue({
|
||||
updatedAt: Date.now(),
|
||||
@@ -1446,241 +1297,6 @@ describe("buildStatusReply subagent summary", () => {
|
||||
expect(providerUsageCall[0]?.providers).toEqual(["deepseek"]);
|
||||
});
|
||||
|
||||
it("uses the session-selected model provider for /status usage", async () => {
|
||||
const usageResetBase = Math.floor(Date.now() / 1000);
|
||||
providerUsageMock.loadProviderUsageSummary.mockImplementation(
|
||||
async ({ providers = [] } = {}) => ({
|
||||
updatedAt: Date.now(),
|
||||
providers: providers.map((provider) =>
|
||||
provider === "openai"
|
||||
? {
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 9,
|
||||
resetAt: (usageResetBase + 60 * 60) * 1000,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
provider,
|
||||
displayName: "DeepSeek",
|
||||
windows: [],
|
||||
summary: "Balance ¥42.50",
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-session-selected-usage",
|
||||
updatedAt: 0,
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.5",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "telegram",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
contextTokens: 1_000_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth (openai:status)",
|
||||
activeModelAuthOverride: "oauth (openai:status)",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash");
|
||||
expect(normalized).toContain("clear /model default");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
expect(normalized).not.toContain("Usage: Balance ¥42.50");
|
||||
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["openai"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the session-selected provider for /status usage when runtime state is stale", async () => {
|
||||
const usageResetBase = Math.floor(Date.now() / 1000);
|
||||
providerUsageMock.loadProviderUsageSummary.mockImplementation(
|
||||
async ({ providers = [] } = {}) => ({
|
||||
updatedAt: Date.now(),
|
||||
providers: providers.map((provider) =>
|
||||
provider === "openai"
|
||||
? {
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 9,
|
||||
resetAt: (usageResetBase + 60 * 60) * 1000,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
provider,
|
||||
displayName: "DeepSeek",
|
||||
windows: [],
|
||||
summary: "Balance ¥42.50",
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-stale-runtime-selected-usage",
|
||||
updatedAt: 0,
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.5",
|
||||
modelOverrideSource: "user",
|
||||
modelProvider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "telegram",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
contextTokens: 1_000_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
modelAuthOverride: "oauth (openai:status)",
|
||||
activeModelAuthOverride: "api-key",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash");
|
||||
expect(normalized).toContain("clear /model default");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
expect(normalized).not.toContain("Usage: Balance ¥42.50");
|
||||
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["openai"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses provider-qualified model overrides for /status usage lookup", async () => {
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
saveStatusTestAuthProfile({ dir, profileId: "openai:status", provider: "openai" });
|
||||
|
||||
const usageResetBase = Math.floor(Date.now() / 1000);
|
||||
providerUsageMock.loadProviderUsageSummary.mockImplementation(
|
||||
async ({ providers = [] } = {}) => ({
|
||||
updatedAt: Date.now(),
|
||||
providers: providers.map((provider) =>
|
||||
provider === "openai"
|
||||
? {
|
||||
provider: "openai",
|
||||
displayName: "OpenAI",
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 9,
|
||||
resetAt: (usageResetBase + 60 * 60) * 1000,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
provider,
|
||||
displayName: "DeepSeek",
|
||||
windows: [],
|
||||
summary: "Balance ¥42.50",
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const text = await buildStatusText({
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
models: [{ ...codexStatusModel, contextWindow: 258_000, contextTokens: 258_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
order: {
|
||||
openai: ["openai:status"],
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "sess-status-qualified-session-selected-usage",
|
||||
updatedAt: 0,
|
||||
modelOverride: "openai/gpt-5.5",
|
||||
},
|
||||
sessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
statusChannel: "telegram",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
contextTokens: 1_000_000,
|
||||
resolvedFastMode: false,
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedReasoningLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
isGroup: false,
|
||||
defaultGroupActivation: () => "mention",
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Model: openai/gpt-5.5");
|
||||
expect(normalized).toContain("pinned session; config primary deepseek/deepseek-v4-flash");
|
||||
expect(normalized).toContain("clear /model default");
|
||||
expect(normalized).toContain("oauth (openai:status)");
|
||||
expect(normalized).toContain("Context: ?/258k");
|
||||
expect(normalized).toContain("Usage: 5h 91% left");
|
||||
expect(normalized).not.toContain("Usage: Balance ¥42.50");
|
||||
expect(providerUsageMock.loadProviderUsageSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["openai"] }),
|
||||
);
|
||||
},
|
||||
{ env: { OPENAI_API_KEY: undefined } },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Codex OAuth auth labels for explicit OpenAI OpenClaw auth order", async () => {
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { withTempDir } from "../../test-helpers/temp-dir.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { resolveCurrentTurnImages } from "./current-turn-images.js";
|
||||
|
||||
@@ -12,9 +11,9 @@ const originalStateDirEnv = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
function restoreProcessState() {
|
||||
if (originalStateDirEnv === undefined) {
|
||||
deleteTestEnvValue("OPENCLAW_STATE_DIR");
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", originalStateDirEnv);
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDirEnv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +33,7 @@ describe("resolveCurrentTurnImages", () => {
|
||||
await fs.mkdir(path.dirname(attachmentPath), { recursive: true });
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
await fs.writeFile(attachmentPath, imageBytes);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
|
||||
const result = await resolveCurrentTurnImages({
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
|
||||
|
||||
const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn());
|
||||
const resolveOutboundAttachmentFromUrl = vi.hoisted(() => vi.fn());
|
||||
const resolveAgentScopedOutboundMediaAccess = vi.hoisted(() => vi.fn());
|
||||
const stateDirEnvSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
|
||||
|
||||
vi.mock("../../agents/sandbox.js", () => ({
|
||||
ensureSandboxWorkspaceForSession,
|
||||
@@ -88,10 +86,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
localRoots: workspaceDir ? [workspaceDir] : undefined,
|
||||
readFile: async () => Buffer.from("image"),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stateDirEnvSnapshot.restore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("stages workspace-relative media through shared outbound attachment loading", async () => {
|
||||
@@ -360,7 +355,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
|
||||
it("keeps managed generated media under the shared media root", async () => {
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
@@ -382,7 +377,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
@@ -411,7 +406,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
await fs.mkdir(path.dirname(symlinkPath), { recursive: true });
|
||||
await fs.writeFile(outsideFile, "secret", "utf8");
|
||||
await fs.symlink(outsideFile, symlinkPath);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
|
||||
@@ -92,14 +92,6 @@ export function expectChannelSurfaceContract(params: {
|
||||
expect(typeof messaging.targetResolver.hint).toBe("string");
|
||||
expect(messaging.targetResolver.hint.trim()).not.toBe("");
|
||||
}
|
||||
if (messaging.targetResolver.reservedLiterals !== undefined) {
|
||||
expect(Array.isArray(messaging.targetResolver.reservedLiterals)).toBe(true);
|
||||
expect(
|
||||
messaging.targetResolver.reservedLiterals.every(
|
||||
(value) => typeof value === "string" && value.trim(),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
if (messaging.targetResolver.resolveTarget) {
|
||||
expect(typeof messaging.targetResolver.resolveTarget).toBe("function");
|
||||
}
|
||||
|
||||
@@ -608,8 +608,6 @@ export type ChannelMessagingAdapter = {
|
||||
targetResolver?: {
|
||||
looksLikeId?: (raw: string, normalized?: string) => boolean;
|
||||
hint?: string;
|
||||
/** Bare words that are command/session references for this channel, not literal destinations. */
|
||||
reservedLiterals?: readonly string[];
|
||||
/**
|
||||
* Plugin-owned fallback for explicit/native targets or post-directory-miss
|
||||
* resolution. This should complement directory lookup, not duplicate it.
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { registerDaemonCli } from "./daemon-cli/register.js";
|
||||
|
||||
const probeGatewayStatus = vi.fn(async (..._args: unknown[]) => ({ ok: true }));
|
||||
@@ -191,10 +191,10 @@ describe("daemon-cli coverage", () => {
|
||||
"OPENCLAW_GATEWAY_PORT",
|
||||
"OPENCLAW_PROFILE",
|
||||
]);
|
||||
setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir);
|
||||
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(tmpDir, "openclaw.json"));
|
||||
deleteTestEnvValue("OPENCLAW_GATEWAY_PORT");
|
||||
deleteTestEnvValue("OPENCLAW_PROFILE");
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = path.join(tmpDir, "openclaw.json");
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
delete process.env.OPENCLAW_PROFILE;
|
||||
serviceReadCommand.mockResolvedValue(null);
|
||||
resolveGatewayProbeAuthSafeWithSecretInputs.mockClear();
|
||||
findExtraGatewayServices.mockClear();
|
||||
|
||||
@@ -15,11 +15,8 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
detectSessionSnapshotHealthIssues,
|
||||
noteSessionSnapshotHealth,
|
||||
scanSessionStoreForStaleRuntimeSnapshotPaths,
|
||||
sessionSnapshotIssueToHealthFinding,
|
||||
sessionSnapshotIssueToRepairEffect,
|
||||
} from "./doctor-session-snapshots.js";
|
||||
|
||||
function sessionEntry(patch: Partial<SessionEntry>): SessionEntry {
|
||||
@@ -69,23 +66,6 @@ async function writeSessionStore(
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2));
|
||||
}
|
||||
|
||||
function readMainSessionEntry(raw: string): SessionEntry {
|
||||
const parsed = JSON.parse(raw) as Record<string, SessionEntry>;
|
||||
const entry = parsed["agent:main"];
|
||||
if (!entry) {
|
||||
throw new Error("expected agent:main session entry");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function readMainSkillsSnapshot(raw: string): NonNullable<SessionEntry["skillsSnapshot"]> {
|
||||
const snapshot = readMainSessionEntry(raw).skillsSnapshot;
|
||||
if (!snapshot) {
|
||||
throw new Error("expected agent:main skills snapshot");
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe("doctor session snapshot stale runtime metadata", () => {
|
||||
let root = "";
|
||||
let bundledSkillsDir = "";
|
||||
@@ -155,57 +135,6 @@ describe("doctor session snapshot stale runtime metadata", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps stale snapshot paths to structured findings and dry-run effects", async () => {
|
||||
const stalePath = path.join(
|
||||
root,
|
||||
"old-runtime",
|
||||
"node_modules",
|
||||
"openclaw",
|
||||
"skills",
|
||||
"doctor",
|
||||
"SKILL.md",
|
||||
);
|
||||
const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json");
|
||||
await writeSessionStore(storePath, {
|
||||
"agent:main": sessionEntry({
|
||||
skillsSnapshot: {
|
||||
prompt: skillPrompt(stalePath),
|
||||
skills: [{ name: "doctor" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const [issue] = await detectSessionSnapshotHealthIssues({
|
||||
storePaths: [storePath],
|
||||
bundledSkillsDir,
|
||||
});
|
||||
|
||||
if (!issue) {
|
||||
throw new Error("expected session snapshot health issue");
|
||||
}
|
||||
expect(issue).toMatchObject({
|
||||
storePath,
|
||||
sessionKey: "agent:main",
|
||||
field: "skillsSnapshot.prompt",
|
||||
cachedPath: stalePath,
|
||||
expectedPath: path.join(bundledSkillsDir, "doctor", "SKILL.md"),
|
||||
});
|
||||
expect(sessionSnapshotIssueToHealthFinding(issue)).toMatchObject({
|
||||
checkId: "core/doctor/session-snapshots",
|
||||
severity: "info",
|
||||
path: storePath,
|
||||
target: stalePath,
|
||||
requirement: expect.stringContaining(bundledSkillsDir),
|
||||
fixHint: expect.stringContaining("openclaw doctor --fix"),
|
||||
});
|
||||
expect(sessionSnapshotIssueToRepairEffect(issue)).toEqual({
|
||||
kind: "file",
|
||||
action: "would-rewrite-session-snapshot-path",
|
||||
target: storePath,
|
||||
dryRunSafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("expands home-relative cached bundled skill locations before classifying them", () => {
|
||||
const homeDir = path.join(root, "home");
|
||||
const stalePath = "~/old-runtime/node_modules/openclaw/skills/doctor/SKILL.md";
|
||||
@@ -527,9 +456,8 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const snapshot = readMainSkillsSnapshot(raw);
|
||||
expect(snapshot.prompt).not.toContain(stalePath);
|
||||
expect(snapshot.prompt).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
|
||||
expect(raw).not.toContain(stalePath);
|
||||
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const [message] = note.mock.calls[0] as [string, string];
|
||||
expect(message).toContain("Repaired");
|
||||
@@ -607,13 +535,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
|
||||
const expectedPath = path.join(bundledSkillsDir, "doctor", "SKILL.md");
|
||||
const snapshot = readMainSkillsSnapshot(raw);
|
||||
const skill = snapshot.resolvedSkills?.[0];
|
||||
expect(skill?.filePath).toBe(expectedPath);
|
||||
expect(skill?.baseDir).toBe(expectedBaseDir);
|
||||
expect(skill?.sourceInfo.path).toBe(expectedPath);
|
||||
expect(skill?.sourceInfo.baseDir).toBe(expectedBaseDir);
|
||||
expect(raw).toContain(path.join(bundledSkillsDir, "doctor", "SKILL.md"));
|
||||
expect(raw).toContain(expectedBaseDir);
|
||||
expect(raw).not.toContain(path.join(root, "old-runtime"));
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const [message] = note.mock.calls[0] as [string, string];
|
||||
expect(message).toContain("Repaired");
|
||||
@@ -652,12 +576,9 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
const snapshot = readMainSkillsSnapshot(raw);
|
||||
const repairedSkill = snapshot.resolvedSkills?.[0];
|
||||
expect(repairedSkill?.filePath).toBe(currentPath);
|
||||
expect(repairedSkill?.baseDir).toBe(path.dirname(currentPath));
|
||||
expect(repairedSkill?.sourceInfo.path).toBe(currentPath);
|
||||
expect(repairedSkill?.sourceInfo.baseDir).toBe(path.dirname(currentPath));
|
||||
expect(raw).toContain(currentPath);
|
||||
expect(raw).toContain(path.dirname(currentPath));
|
||||
expect(raw).not.toContain(path.join(root, "old-runtime"));
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const [message] = note.mock.calls[0] as [string, string];
|
||||
expect(message).toContain("Repaired");
|
||||
@@ -822,8 +743,7 @@ describe("doctor session snapshot repair (shouldRepair)", () => {
|
||||
expect(backupFiles.length).toBe(1);
|
||||
|
||||
const backupContent = await fs.readFile(path.join(dir, backupFiles[0]), "utf-8");
|
||||
const backupSnapshot = readMainSkillsSnapshot(backupContent);
|
||||
expect(backupSnapshot.prompt).toContain(stalePath);
|
||||
expect(backupContent).toContain(stalePath);
|
||||
});
|
||||
|
||||
it("is idempotent — second repair finds nothing", async () => {
|
||||
|
||||
@@ -12,14 +12,11 @@ import {
|
||||
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||
import { writeTextAtomic } from "../infra/json-files.js";
|
||||
import { resolveBundledSkillsDir } from "../skills/loading/bundled-dir.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
const SESSION_SNAPSHOTS_CHECK_ID = "core/doctor/session-snapshots";
|
||||
|
||||
type SnapshotPathSource =
|
||||
| "skillsSnapshot.prompt"
|
||||
| "skillsSnapshot.resolvedSkills"
|
||||
@@ -37,10 +34,6 @@ type StaleSessionSnapshotPathFinding = {
|
||||
expectedPath: string;
|
||||
};
|
||||
|
||||
export type SessionSnapshotHealthIssue = StaleSessionSnapshotPathFinding & {
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
function decodeXmlText(value: string): string {
|
||||
return value
|
||||
.replace(/</g, "<")
|
||||
@@ -293,72 +286,6 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record<string, Sess
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function detectSessionSnapshotHealthIssues(params?: {
|
||||
storePaths?: string[];
|
||||
bundledSkillsDir?: string;
|
||||
cfg?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<SessionSnapshotHealthIssue[]> {
|
||||
const bundledSkillsDir = params?.bundledSkillsDir ?? resolveBundledSkillsDir();
|
||||
if (!bundledSkillsDir) {
|
||||
return [];
|
||||
}
|
||||
const storePaths =
|
||||
params?.storePaths ??
|
||||
resolveSessionStorePaths({ cfg: params?.cfg, env: params?.env }) ??
|
||||
(await listSessionStorePaths(resolveStateDir(params?.env)));
|
||||
const issues: SessionSnapshotHealthIssue[] = [];
|
||||
for (const storePath of storePaths) {
|
||||
let store: Record<string, SessionEntry>;
|
||||
try {
|
||||
store = loadSessionStoreForSnapshotScan(storePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const findings = scanSessionStoreForStaleRuntimeSnapshotPaths({
|
||||
store,
|
||||
bundledSkillsDir,
|
||||
env: params?.env,
|
||||
});
|
||||
for (const finding of findings) {
|
||||
issues.push({
|
||||
sessionKey: finding.sessionKey,
|
||||
field: finding.field,
|
||||
cachedPath: finding.cachedPath,
|
||||
expectedPath: finding.expectedPath,
|
||||
storePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function sessionSnapshotIssueToHealthFinding(
|
||||
issue: SessionSnapshotHealthIssue,
|
||||
): HealthFinding {
|
||||
return {
|
||||
checkId: SESSION_SNAPSHOTS_CHECK_ID,
|
||||
severity: "info",
|
||||
message: `${issue.sessionKey} cached session metadata references an inactive runtime root that can be cleaned up.`,
|
||||
path: issue.storePath,
|
||||
target: issue.cachedPath,
|
||||
requirement: `Current bundled skill path: ${issue.expectedPath}`,
|
||||
fixHint:
|
||||
"To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite stale cached session metadata paths, or start a fresh session after confirming history can be retired.",
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionSnapshotIssueToRepairEffect(
|
||||
issue: SessionSnapshotHealthIssue,
|
||||
): HealthRepairEffect {
|
||||
return {
|
||||
kind: "file",
|
||||
action: "would-rewrite-session-snapshot-path",
|
||||
target: issue.storePath,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces stale paths in raw, JSON-escaped, and XML-escaped prompt text. */
|
||||
function replaceStalePathsInText(text: string, finding: StaleSessionSnapshotPathFinding): string {
|
||||
const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1);
|
||||
|
||||
@@ -12,11 +12,8 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
detectSessionTranscriptHealthIssues,
|
||||
noteSessionTranscriptHealth,
|
||||
repairBrokenSessionTranscriptFile,
|
||||
sessionTranscriptIssueToHealthFinding,
|
||||
sessionTranscriptIssueToRepairEffect,
|
||||
} from "./doctor-session-transcripts.js";
|
||||
|
||||
function countNonEmptyLines(value: string): number {
|
||||
@@ -153,44 +150,6 @@ describe("doctor session transcript repair", () => {
|
||||
expect(countNonEmptyLines(await fs.readFile(filePath, "utf-8"))).toBe(3);
|
||||
});
|
||||
|
||||
it("maps affected transcripts to structured findings and dry-run effects", async () => {
|
||||
const filePath = await writeTranscript([
|
||||
{ type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" },
|
||||
{
|
||||
type: "message",
|
||||
id: "legacy-assistant",
|
||||
parentId: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const sessionsDir = path.dirname(filePath);
|
||||
|
||||
const [issue] = await detectSessionTranscriptHealthIssues({ sessionDirs: [sessionsDir] });
|
||||
|
||||
if (!issue) {
|
||||
throw new Error("expected session transcript health issue");
|
||||
}
|
||||
expect(issue?.filePath).toBe(filePath);
|
||||
expect(sessionTranscriptIssueToHealthFinding(issue)).toMatchObject({
|
||||
checkId: "core/doctor/session-transcripts",
|
||||
severity: "info",
|
||||
path: filePath,
|
||||
fixHint: expect.stringContaining("openclaw doctor --fix"),
|
||||
});
|
||||
expect(sessionTranscriptIssueToRepairEffect(issue)).toEqual({
|
||||
kind: "file",
|
||||
action: "would-rewrite-session-transcript",
|
||||
target: filePath,
|
||||
dryRunSafe: false,
|
||||
});
|
||||
expect(await fs.readFile(filePath, "utf-8")).toContain("openai-codex");
|
||||
});
|
||||
|
||||
it("repairs supported current-version linear transcripts", async () => {
|
||||
const filePath = await writeTranscript([
|
||||
{ type: "session", version: 3, id: "session-linear", timestamp: "2026-06-15T00:00:00Z" },
|
||||
|
||||
@@ -16,11 +16,8 @@ import {
|
||||
scanSessionTranscriptTree,
|
||||
selectSessionTranscriptTreePathNodes,
|
||||
} from "../config/sessions/transcript-tree.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
|
||||
const SESSION_TRANSCRIPTS_CHECK_ID = "core/doctor/session-transcripts";
|
||||
|
||||
type TranscriptEntry = Record<string, unknown> & {
|
||||
id?: unknown;
|
||||
parentId?: unknown;
|
||||
@@ -39,10 +36,6 @@ type TranscriptRepairResult = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SessionTranscriptHealthIssue = TranscriptRepairResult & {
|
||||
broken: true;
|
||||
};
|
||||
|
||||
type ActiveTranscriptPath = {
|
||||
entries: TranscriptEntry[];
|
||||
entriesToPersist: TranscriptEntry[];
|
||||
@@ -379,57 +372,6 @@ async function listSessionTranscriptFiles(sessionDirs: string[]): Promise<string
|
||||
return files.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export async function detectSessionTranscriptHealthIssues(params?: {
|
||||
sessionDirs?: string[];
|
||||
}): Promise<SessionTranscriptHealthIssue[]> {
|
||||
let sessionDirs = params?.sessionDirs;
|
||||
try {
|
||||
sessionDirs ??= await resolveAgentSessionDirs(resolveStateDir(process.env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await listSessionTranscriptFiles(sessionDirs);
|
||||
const issues: SessionTranscriptHealthIssue[] = [];
|
||||
for (const filePath of files) {
|
||||
const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: false });
|
||||
if (result.broken) {
|
||||
issues.push(result as SessionTranscriptHealthIssue);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function sessionTranscriptIssueToHealthFinding(
|
||||
issue: SessionTranscriptHealthIssue,
|
||||
): HealthFinding {
|
||||
const metadata =
|
||||
issue.legacyOpenAICodexEntries > 0
|
||||
? ` ${issue.legacyOpenAICodexEntries} legacy OpenAI Codex metadata entr${
|
||||
issue.legacyOpenAICodexEntries === 1 ? "y" : "ies"
|
||||
}`
|
||||
: "";
|
||||
return {
|
||||
checkId: SESSION_TRANSCRIPTS_CHECK_ID,
|
||||
severity: "info",
|
||||
message: `Session transcript has legacy branch or provider metadata that can be cleaned up.${metadata}`,
|
||||
path: issue.filePath,
|
||||
fixHint:
|
||||
"To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite affected transcripts to their active branch.",
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionTranscriptIssueToRepairEffect(
|
||||
issue: SessionTranscriptHealthIssue,
|
||||
): HealthRepairEffect {
|
||||
return {
|
||||
kind: "file",
|
||||
action: "would-rewrite-session-transcript",
|
||||
target: issue.filePath,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Scans session transcript files and reports or repairs legacy/broken transcript state. */
|
||||
export async function noteSessionTranscriptHealth(params?: {
|
||||
shouldRepair?: boolean;
|
||||
@@ -444,14 +386,14 @@ export async function noteSessionTranscriptHealth(params?: {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await listSessionTranscriptFiles(sessionDirs);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results: TranscriptRepairResult[] = [];
|
||||
if (shouldRepair) {
|
||||
const files = await listSessionTranscriptFiles(sessionDirs);
|
||||
for (const filePath of files) {
|
||||
results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair }));
|
||||
}
|
||||
} else {
|
||||
results.push(...(await detectSessionTranscriptHealthIssues({ sessionDirs })));
|
||||
for (const filePath of files) {
|
||||
results.push(await repairBrokenSessionTranscriptFile({ filePath, shouldRepair }));
|
||||
}
|
||||
const broken = results.filter((result) => result.broken);
|
||||
if (broken.length === 0) {
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("cron activeJobIds — manual-run mark/clear", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sends one setup-timeout notification when concurrent manual runs both stall before runner start", async () => {
|
||||
it("requests one setup-timeout restart when concurrent manual runs both stall before runner start", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.parse("2025-12-13T17:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Isolated agent delivery target tests cover target resolution for cron runs.
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelOutboundAdapter,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import {
|
||||
@@ -744,57 +741,6 @@ describe("resolveDeliveryTarget", () => {
|
||||
expect(result.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves cron reserved explicit targets through directory entries", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
const listGroups = vi.fn(async () => [
|
||||
{
|
||||
kind: "group",
|
||||
id: "-1002458651455",
|
||||
name: "current",
|
||||
handle: "@current",
|
||||
} satisfies ChannelDirectoryEntry,
|
||||
]);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: createStubOutbound("Telegram"),
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
messaging: {
|
||||
...telegramMessagingForTest,
|
||||
normalizeTarget: normalizeTelegramTargetForDeliveryTest,
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
}),
|
||||
directory: { listGroups },
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
|
||||
channel: "telegram",
|
||||
to: "current",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.to).toBe("-1002458651455");
|
||||
expect(result.threadId).toBeUndefined();
|
||||
expect(listGroups).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: undefined,
|
||||
query: "current",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses canonical route targets even when the route has no thread", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
setActivePluginRegistry(
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { stripTargetProviderPrefix } from "../../infra/outbound/channel-target-prefix.js";
|
||||
import type { OutboundSessionRoute } from "../../infra/outbound/outbound-session.js";
|
||||
import { isReservedTargetLiteralError } from "../../infra/outbound/target-errors.js";
|
||||
import type { ResolvedMessagingTarget } from "../../infra/outbound/target-resolver.js";
|
||||
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
|
||||
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
|
||||
@@ -350,20 +349,17 @@ export async function resolveDeliveryTarget(
|
||||
allowFrom: effectiveAllowFrom,
|
||||
});
|
||||
if (!docked.ok) {
|
||||
if (!toCandidate || !isReservedTargetLiteralError(docked.error)) {
|
||||
return {
|
||||
ok: false,
|
||||
channel,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId: explicitThreadId,
|
||||
mode,
|
||||
error: docked.error,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
toCandidate = docked.to;
|
||||
return {
|
||||
ok: false,
|
||||
channel,
|
||||
to: undefined,
|
||||
accountId,
|
||||
threadId: explicitThreadId,
|
||||
mode,
|
||||
error: docked.error,
|
||||
};
|
||||
}
|
||||
toCandidate = docked.to;
|
||||
const targetResolution = await deliveryTargetRuntime.resolveChannelTargetForDelivery({
|
||||
cfg,
|
||||
channel,
|
||||
|
||||
@@ -236,7 +236,7 @@ export function createMockCronStateForJobs(params: {
|
||||
stopped: false,
|
||||
restartRecoveryPending: false,
|
||||
activeManualRunJobIds: new Set<string>(),
|
||||
manualSetupTimeoutNotified: false,
|
||||
manualSetupTimeoutRestartNotified: false,
|
||||
timer: null,
|
||||
storeLoadedAtMs: nowMs,
|
||||
op: Promise.resolve(),
|
||||
|
||||
@@ -86,7 +86,7 @@ function clearManualCronJobActive(
|
||||
state.activeManualRunJobIds.delete(jobId);
|
||||
clearCronJobActive(jobId, activeJobMarker);
|
||||
if (state.activeManualRunJobIds.size === 0) {
|
||||
state.manualSetupTimeoutNotified = false;
|
||||
state.manualSetupTimeoutRestartNotified = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,11 @@ function maybeNotifyManualIsolatedSetupTimeout(
|
||||
isolatedAgentSetupTimeout?: IsolatedAgentSetupTimeoutSignal;
|
||||
},
|
||||
): boolean {
|
||||
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutNotified) {
|
||||
if (!result.isolatedAgentSetupTimeout || state.manualSetupTimeoutRestartNotified) {
|
||||
return false;
|
||||
}
|
||||
const notified = maybeNotifyIsolatedAgentSetupTimeout(state, result);
|
||||
state.manualSetupTimeoutNotified ||= notified;
|
||||
state.manualSetupTimeoutRestartNotified ||= notified;
|
||||
return notified;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ export type CronServiceState = {
|
||||
stopped: boolean;
|
||||
restartRecoveryPending: boolean;
|
||||
activeManualRunJobIds: Set<string>;
|
||||
manualSetupTimeoutNotified: boolean;
|
||||
manualSetupTimeoutRestartNotified: boolean;
|
||||
/** Serializes mutating service operations so store writes and timers stay ordered. */
|
||||
op: Promise<unknown>;
|
||||
warnedDisabled: boolean;
|
||||
@@ -221,7 +221,7 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState
|
||||
stopped: false,
|
||||
restartRecoveryPending: false,
|
||||
activeManualRunJobIds: new Set<string>(),
|
||||
manualSetupTimeoutNotified: false,
|
||||
manualSetupTimeoutRestartNotified: false,
|
||||
op: Promise.resolve(),
|
||||
warnedDisabled: false,
|
||||
warnedInvalidPersistedJobKeys: new Set<string>(),
|
||||
|
||||
@@ -1301,7 +1301,7 @@ describe("cron service timer regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies setup timeout after startup catch-up finalization", async () => {
|
||||
it("notifies setup-timeout restart after startup catch-up finalization", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -1926,7 +1926,7 @@ describe("cron service timer regressions", () => {
|
||||
expect(jobs.find((job) => job.id === second.id)?.state.lastStatus).toBe("ok");
|
||||
});
|
||||
|
||||
it("sends one setup-timeout notification when a concurrent cron batch stalls before runners start", async () => {
|
||||
it("requests one setup-timeout restart when a concurrent cron batch stalls before runners start", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -1990,7 +1990,7 @@ describe("cron service timer regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sends setup-timeout notification after a prior serial cron job completes", async () => {
|
||||
it("requests setup-timeout restart after a prior serial cron job completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -2058,7 +2058,7 @@ describe("cron service timer regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sends setup-timeout notification when manual and scheduled runs both stall", async () => {
|
||||
it("requests setup-timeout restart when manual and scheduled runs both stall", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -2128,7 +2128,7 @@ describe("cron service timer regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rearms scheduled jobs after manual setup timeout notification", async () => {
|
||||
it("suppresses scheduled rearm after manual setup-timeout restart request", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -2179,8 +2179,8 @@ describe("cron service timer regressions", () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(state.restartRecoveryPending).toBe(false);
|
||||
expect(state.timer).not.toBeNull();
|
||||
expect(state.restartRecoveryPending).toBe(true);
|
||||
expect(state.timer).toBeNull();
|
||||
expect(scheduledStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -2352,7 +2352,7 @@ describe("cron service timer regressions", () => {
|
||||
).toBe(replacementReservationMs);
|
||||
});
|
||||
|
||||
it("continues an active scheduled batch after manual setup-timeout notification", async () => {
|
||||
it("stops an active scheduled batch from claiming more jobs after manual setup-timeout recovery", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -2419,14 +2419,14 @@ describe("cron service timer regressions", () => {
|
||||
await vi.advanceTimersByTimeAsync(60_100);
|
||||
now += 60_100;
|
||||
await manualRun;
|
||||
expect(state.restartRecoveryPending).toBe(false);
|
||||
expect(state.restartRecoveryPending).toBe(true);
|
||||
|
||||
finishFirstScheduled.resolve();
|
||||
await timerRun;
|
||||
|
||||
const second = requireJob(state, secondScheduledJob.id);
|
||||
expect(onIsolatedAgentSetupTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(secondScheduledStarted).toHaveBeenCalledWith(secondScheduledJob.id);
|
||||
expect(secondScheduledStarted).not.toHaveBeenCalled();
|
||||
expect(second.state.runningAtMs).toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -2794,7 +2794,7 @@ describe("cron service timer regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not notify setup timeout for cron-nested lane contention", async () => {
|
||||
it("does not request setup-timeout restart for cron-nested lane contention", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
@@ -2854,7 +2854,7 @@ describe("cron service timer regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not notify setup timeout for custom-session cron waits", async () => {
|
||||
it("does not notify setup-timeout restart for custom-session cron waits", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = timerRegressionFixtures.makeStorePath();
|
||||
|
||||
@@ -344,6 +344,7 @@ export function maybeNotifyIsolatedAgentSetupTimeout(
|
||||
if (!notified) {
|
||||
return false;
|
||||
}
|
||||
state.restartRecoveryPending = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
archiveLegacyCronStoreForMigration,
|
||||
loadLegacyCronStoreForMigration,
|
||||
} from "../commands/doctor/cron/legacy-store-migration.js";
|
||||
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
||||
import {
|
||||
loadCronJobsStoreWithConfigJobs,
|
||||
loadCronJobsStoreSync,
|
||||
@@ -80,15 +79,13 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe("resolveCronStorePath", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_HOME", "HOME"]);
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_HOME for tilde expansion", () => {
|
||||
setTestEnvValue("OPENCLAW_HOME", "/srv/openclaw-home");
|
||||
setTestEnvValue("HOME", "/home/other");
|
||||
vi.stubEnv("OPENCLAW_HOME", "/srv/openclaw-home");
|
||||
vi.stubEnv("HOME", "/home/other");
|
||||
|
||||
const result = resolveCronStorePath("~/cron/jobs.json");
|
||||
expect(result).toBe(path.resolve("/srv/openclaw-home", "cron", "jobs.json"));
|
||||
|
||||
@@ -1014,8 +1014,6 @@ describe("doctor health contributions", () => {
|
||||
expect(contributionIds).toContain("core/doctor/sandbox/registry-files");
|
||||
expect(contributionIds).toContain("core/doctor/gateway-services/extra");
|
||||
expect(contributionIds).toContain("core/doctor/config-audit-scrub");
|
||||
expect(contributionIds).toContain("core/doctor/session-transcripts");
|
||||
expect(contributionIds).toContain("core/doctor/session-snapshots");
|
||||
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
|
||||
});
|
||||
|
||||
|
||||
@@ -1298,71 +1298,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:session-transcripts",
|
||||
label: "Session transcripts",
|
||||
healthChecks: {
|
||||
id: "core/doctor/session-transcripts",
|
||||
description: "Legacy or branchy session transcript files are represented as findings.",
|
||||
async detect() {
|
||||
const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToHealthFinding } =
|
||||
await import("../commands/doctor-session-transcripts.js");
|
||||
return (await detectSessionTranscriptHealthIssues()).map(
|
||||
sessionTranscriptIssueToHealthFinding,
|
||||
);
|
||||
},
|
||||
async repair(ctx) {
|
||||
const { detectSessionTranscriptHealthIssues, sessionTranscriptIssueToRepairEffect } =
|
||||
await import("../commands/doctor-session-transcripts.js");
|
||||
const effects = (await detectSessionTranscriptHealthIssues()).map(
|
||||
sessionTranscriptIssueToRepairEffect,
|
||||
);
|
||||
if (ctx.dryRun === true) {
|
||||
return { status: "repaired", changes: [], effects };
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "legacy doctor session transcript contribution owns transcript rewrites",
|
||||
changes: [],
|
||||
effects,
|
||||
};
|
||||
},
|
||||
},
|
||||
run: runSessionTranscriptsHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:session-snapshots",
|
||||
label: "Session snapshots",
|
||||
healthChecks: {
|
||||
id: "core/doctor/session-snapshots",
|
||||
description: "Stale cached session snapshot paths are represented as findings.",
|
||||
async detect(ctx) {
|
||||
const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToHealthFinding } =
|
||||
await import("../commands/doctor-session-snapshots.js");
|
||||
return (
|
||||
await detectSessionSnapshotHealthIssues({
|
||||
cfg: ctx.cfg,
|
||||
env: process.env,
|
||||
})
|
||||
).map(sessionSnapshotIssueToHealthFinding);
|
||||
},
|
||||
async repair(ctx) {
|
||||
const { detectSessionSnapshotHealthIssues, sessionSnapshotIssueToRepairEffect } =
|
||||
await import("../commands/doctor-session-snapshots.js");
|
||||
const effects = (
|
||||
await detectSessionSnapshotHealthIssues({
|
||||
cfg: ctx.cfg,
|
||||
env: process.env,
|
||||
})
|
||||
).map(sessionSnapshotIssueToRepairEffect);
|
||||
if (ctx.dryRun === true) {
|
||||
return { status: "repaired", changes: [], effects };
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "legacy doctor session snapshot contribution owns snapshot rewrites",
|
||||
changes: [],
|
||||
effects,
|
||||
};
|
||||
},
|
||||
},
|
||||
run: runSessionSnapshotsHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
|
||||
@@ -456,34 +456,6 @@ describe("channel-health-monitor", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("continues pending recovery on the next check without waiting for cooldown", async () => {
|
||||
const account: Partial<ChannelAccountSnapshot> = disconnectedAccount(Date.now() - 300_000);
|
||||
const manager = createSnapshotManager(
|
||||
{
|
||||
discord: {
|
||||
default: account,
|
||||
},
|
||||
},
|
||||
{
|
||||
startChannel: vi.fn(async () => {
|
||||
account.running = false;
|
||||
account.connected = false;
|
||||
account.restartPending = true;
|
||||
account.reconnectAttempts = 0;
|
||||
}),
|
||||
},
|
||||
);
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).toHaveBeenCalledTimes(1);
|
||||
expect(manager.startChannel).toHaveBeenCalledTimes(1);
|
||||
|
||||
await advanceHealthCheck();
|
||||
|
||||
expect(manager.stopChannel).toHaveBeenCalledTimes(1);
|
||||
expect(manager.startChannel).toHaveBeenCalledTimes(2);
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("caps at 3 health-monitor restarts per channel per hour", async () => {
|
||||
const manager = createSnapshotManager({
|
||||
discord: {
|
||||
|
||||
@@ -145,20 +145,12 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
|
||||
restartsThisHour: [],
|
||||
};
|
||||
|
||||
const continuingPendingRestart =
|
||||
status.running !== true &&
|
||||
status.restartPending === true &&
|
||||
(status.reconnectAttempts ?? 0) === 0;
|
||||
|
||||
// A timed-out recovery stop uses the first start request to mark
|
||||
// restartPending; the next monitor pass must finish that same recovery
|
||||
// instead of waiting behind this monitor's fresh-restart cooldown.
|
||||
if (!continuingPendingRestart && now - record.lastRestartAt <= cooldownMs) {
|
||||
if (now - record.lastRestartAt <= cooldownMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pruneOldRestarts(record, now);
|
||||
if (!continuingPendingRestart && record.restartsThisHour.length >= maxRestartsPerHour) {
|
||||
if (record.restartsThisHour.length >= maxRestartsPerHour) {
|
||||
log.warn?.(
|
||||
`[${channelId}:${accountId}] health-monitor: hit ${maxRestartsPerHour} restarts/hour limit, skipping`,
|
||||
);
|
||||
@@ -169,11 +161,9 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
|
||||
|
||||
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
|
||||
|
||||
if (!continuingPendingRestart) {
|
||||
record.lastRestartAt = now;
|
||||
record.restartsThisHour.push({ at: now });
|
||||
restartRecords.set(key, record);
|
||||
}
|
||||
record.lastRestartAt = now;
|
||||
record.restartsThisHour.push({ at: now });
|
||||
restartRecords.set(key, record);
|
||||
|
||||
try {
|
||||
if (status.running) {
|
||||
|
||||
@@ -511,82 +511,6 @@ describe("server-channels auto restart", () => {
|
||||
expect(account?.lastError).toContain("channel stop timed out");
|
||||
});
|
||||
|
||||
it("resumes startup on the second recovery pass while the stale task is still pending", async () => {
|
||||
const startAccount = vi.fn(async ({ abortSignal }: { abortSignal: AbortSignal }) => {
|
||||
abortSignal.addEventListener("abort", () => {}, { once: true });
|
||||
await new Promise<void>(() => {});
|
||||
});
|
||||
installTestRegistry(
|
||||
createTestPlugin({
|
||||
startAccount,
|
||||
}),
|
||||
);
|
||||
const manager = createManager();
|
||||
|
||||
await manager.startChannels();
|
||||
const recoveryStopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, {
|
||||
manual: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await recoveryStopTask;
|
||||
|
||||
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
|
||||
let account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
||||
expect(startAccount).toHaveBeenCalledTimes(1);
|
||||
expect(account?.running).toBe(false);
|
||||
expect(account?.restartPending).toBe(true);
|
||||
|
||||
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
|
||||
|
||||
account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
||||
expect(startAccount).toHaveBeenCalledTimes(2);
|
||||
expect(account?.running).toBe(true);
|
||||
expect(account?.restartPending).toBe(false);
|
||||
expect(account?.reconnectAttempts).toBe(0);
|
||||
expect(account?.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the second recovery task running when the stale task rejects", async () => {
|
||||
const releaseFirstTask = createDeferred();
|
||||
let startCount = 0;
|
||||
const startAccount = vi.fn(async ({ abortSignal }: { abortSignal: AbortSignal }) => {
|
||||
startCount += 1;
|
||||
abortSignal.addEventListener("abort", () => {}, { once: true });
|
||||
if (startCount === 1) {
|
||||
await releaseFirstTask.promise;
|
||||
throw new Error("late stale worker exit");
|
||||
}
|
||||
await new Promise<void>(() => {});
|
||||
});
|
||||
installTestRegistry(
|
||||
createTestPlugin({
|
||||
startAccount,
|
||||
}),
|
||||
);
|
||||
const manager = createManager();
|
||||
|
||||
await manager.startChannels();
|
||||
const recoveryStopTask = manager.stopChannel("discord", DEFAULT_ACCOUNT_ID, {
|
||||
manual: false,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await recoveryStopTask;
|
||||
|
||||
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
|
||||
await manager.startChannel("discord", DEFAULT_ACCOUNT_ID);
|
||||
expect(startAccount).toHaveBeenCalledTimes(2);
|
||||
|
||||
releaseFirstTask.resolve();
|
||||
await flushMicrotasks();
|
||||
|
||||
const account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
|
||||
expect(startAccount).toHaveBeenCalledTimes(2);
|
||||
expect(account?.running).toBe(true);
|
||||
expect(account?.restartPending).toBe(false);
|
||||
expect(account?.lastError).toBeNull();
|
||||
expect(hoisted.sleepWithAbort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts immediately when recovery stop timeout settles with an error", async () => {
|
||||
const rejectFirstTask = createDeferred();
|
||||
let startCount = 0;
|
||||
|
||||
@@ -449,7 +449,6 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
tasks: accountIds.map((id) => async () => {
|
||||
const rKey = restartKey(channelId, id);
|
||||
if (store.tasks.has(id)) {
|
||||
let clearedTimedOutRecoveryTask = false;
|
||||
if (recoveryStopTimedOut.has(rKey)) {
|
||||
if (!preserveManualStop) {
|
||||
manuallyStopped.delete(rKey);
|
||||
@@ -457,30 +456,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
if (manuallyStopped.has(rKey)) {
|
||||
return;
|
||||
}
|
||||
// When a previous stop timed out and the health monitor is
|
||||
// requesting recovery again, clean up the stuck task so the
|
||||
// channel can actually restart instead of staying in limbo.
|
||||
if (recoveryStartRequested.has(rKey)) {
|
||||
recoveryStopTimedOut.delete(rKey);
|
||||
recoveryStartRequested.delete(rKey);
|
||||
restartAttempts.delete(rKey);
|
||||
store.aborts.delete(id);
|
||||
store.tasks.delete(id);
|
||||
clearedTimedOutRecoveryTask = true;
|
||||
setRuntime(channelId, id, {
|
||||
accountId: id,
|
||||
restartPending: false,
|
||||
reconnectAttempts: 0,
|
||||
});
|
||||
} else {
|
||||
recoveryStartRequested.add(rKey);
|
||||
setRuntime(channelId, id, { accountId: id, restartPending: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!clearedTimedOutRecoveryTask) {
|
||||
return;
|
||||
recoveryStartRequested.add(rKey);
|
||||
setRuntime(channelId, id, { accountId: id, restartPending: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const existingStart = store.starting.get(id);
|
||||
if (existingStart) {
|
||||
@@ -628,10 +607,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
abortSignal: abort.signal,
|
||||
log,
|
||||
getStatus: () => getRuntime(channelId, id),
|
||||
setStatus: (next) =>
|
||||
isCurrentTask()
|
||||
? setRuntimeFromTaskStatus(channelId, id, next, abort.signal)
|
||||
: getRuntime(channelId, id),
|
||||
setStatus: (next) => setRuntimeFromTaskStatus(channelId, id, next, abort.signal),
|
||||
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
|
||||
});
|
||||
const routeRegistry = getPluginHttpRouteRegistry?.();
|
||||
@@ -644,11 +620,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
}
|
||||
await startAccountTask;
|
||||
});
|
||||
// Recovery can replace a timed-out task before the old promise settles.
|
||||
// Only the task that still owns the store slot may write lifecycle state.
|
||||
const trackedPromise = task
|
||||
.then(() => {
|
||||
if (abort.signal.aborted || manuallyStopped.has(rKey) || !isCurrentTask()) {
|
||||
if (abort.signal.aborted || manuallyStopped.has(rKey)) {
|
||||
return;
|
||||
}
|
||||
const message = "channel exited without an error";
|
||||
@@ -656,26 +630,17 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
log.error?.(`[${id}] ${message}`);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!isCurrentTask()) {
|
||||
return;
|
||||
}
|
||||
const message = formatErrorMessage(err);
|
||||
setRuntime(channelId, id, { accountId: id, lastError: message });
|
||||
log.error?.(`[${id}] channel exited: ${message}`);
|
||||
})
|
||||
.then(async () => {
|
||||
await cleanupTaskScopedApprovalRuntime("channel cleanup failed");
|
||||
if (!isCurrentTask()) {
|
||||
return;
|
||||
}
|
||||
setStoppedRuntime(channelId, id, {
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
})
|
||||
.then(async () => {
|
||||
if (!isCurrentTask()) {
|
||||
return;
|
||||
}
|
||||
if (manuallyStopped.has(rKey)) {
|
||||
recoveryStopTimedOut.delete(rKey);
|
||||
recoveryStartRequested.delete(rKey);
|
||||
@@ -766,9 +731,6 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
store.aborts.delete(id);
|
||||
}
|
||||
});
|
||||
function isCurrentTask() {
|
||||
return store.tasks.get(id) === trackedPromise;
|
||||
}
|
||||
handedOffTask = true;
|
||||
store.tasks.set(id, trackedPromise);
|
||||
} catch (error) {
|
||||
|
||||
@@ -285,7 +285,7 @@ describe("buildGatewayCronService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("backs off isolated cron setup timeout without gateway restart", async () => {
|
||||
it("requests a safe gateway restart when isolated cron setup times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = createCronConfig("server-cron-isolated-setup-timeout");
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
@@ -315,7 +315,12 @@ describe("buildGatewayCronService", () => {
|
||||
const runResult = await runPromise;
|
||||
|
||||
expect(runResult).toEqual({ ok: true, ran: true });
|
||||
expect(requestSafeGatewayRestartMock).not.toHaveBeenCalled();
|
||||
expect(requestSafeGatewayRestartMock).toHaveBeenCalledTimes(1);
|
||||
expect(requestSafeGatewayRestartMock).toHaveBeenCalledWith({
|
||||
reason: "cron.isolated_agent_setup_timeout",
|
||||
delayMs: 0,
|
||||
preservePendingEmitHooks: true,
|
||||
});
|
||||
} finally {
|
||||
state.cron.stop();
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -32,6 +32,7 @@ import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { resolveMainScopedEventSessionKey } from "../infra/event-session-routing.js";
|
||||
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
|
||||
import { requestHeartbeat } from "../infra/heartbeat-wake.js";
|
||||
import { requestSafeGatewayRestart } from "../infra/restart-coordinator.js";
|
||||
import {
|
||||
consumeSelectedSystemEventEntries,
|
||||
enqueueSystemEventEntry,
|
||||
@@ -546,14 +547,23 @@ export function buildGatewayCronService(params: {
|
||||
}).catch(() => {});
|
||||
},
|
||||
onIsolatedAgentSetupTimeout: ({ job, error, timeoutMs }) => {
|
||||
const restart = requestSafeGatewayRestart({
|
||||
reason: "cron.isolated_agent_setup_timeout",
|
||||
delayMs: 0,
|
||||
preservePendingEmitHooks: true,
|
||||
});
|
||||
cronLogger.warn(
|
||||
{
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
timeoutMs,
|
||||
error,
|
||||
restartStatus: restart.status,
|
||||
restartCoalesced: restart.restart.coalesced,
|
||||
restartSummary: restart.preflight.summary,
|
||||
restartDelayMs: restart.restart.delayMs,
|
||||
},
|
||||
"cron: isolated agent setup timed out before runner start; backing off job without gateway restart",
|
||||
"cron: isolated agent setup timed out before runner start; requested safe gateway restart",
|
||||
);
|
||||
},
|
||||
sendCronFailureAlert: async ({ job, text, channel, to, mode, accountId }) =>
|
||||
|
||||
@@ -2300,7 +2300,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
let resolvedTo = deliveryPlan.resolvedTo;
|
||||
let effectivePlan = deliveryPlan;
|
||||
let deliveryDowngradeReason: string | null = null;
|
||||
let deliveryTargetResolutionError: Error | undefined = deliveryPlan.targetResolutionError;
|
||||
let deliveryTargetResolutionError: Error | undefined;
|
||||
|
||||
if (wantsDelivery && resolvedChannel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
const cfgResolved = cfgForAgent ?? cfg;
|
||||
@@ -2328,27 +2328,6 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsDelivery && deliveryTargetResolutionError) {
|
||||
if (!bestEffortDeliver) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(deliveryTargetResolutionError)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
deliveryDowngradeReason = String(deliveryTargetResolutionError);
|
||||
resolvedChannel = INTERNAL_MESSAGE_CHANNEL;
|
||||
deliveryTargetMode = undefined;
|
||||
resolvedTo = undefined;
|
||||
effectivePlan = {
|
||||
...deliveryPlan,
|
||||
resolvedChannel,
|
||||
resolvedTo,
|
||||
deliveryTargetMode,
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
|
||||
const cfgResolved = cfgForAgent ?? cfg;
|
||||
const fallback = resolveAgentOutboundTarget({
|
||||
|
||||
@@ -923,37 +923,6 @@ describe("gateway send mirroring", () => {
|
||||
expect(response?.[2]?.message).toContain("Use `chat.send`");
|
||||
});
|
||||
|
||||
it("accepts bundled channels before plugin registry normalization for message actions", async () => {
|
||||
const { respond } = await runMessageActionRequest({
|
||||
channel: "TELEGRAM",
|
||||
action: "send",
|
||||
params: { target: "123", message: "hi" },
|
||||
idempotencyKey: "idem-telegram-message-action",
|
||||
});
|
||||
|
||||
const call = lastDispatchChannelMessageActionCall();
|
||||
expect(call?.channel).toBe("telegram");
|
||||
expect(firstRespondCall(respond)[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown send channels without delivering", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValue(undefined);
|
||||
|
||||
const { respond } = await runSend({
|
||||
to: "x",
|
||||
message: "hi",
|
||||
channel: "definitely-not-a-real-channel-xyz",
|
||||
idempotencyKey: "idem-unknown-channel",
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
const response = firstRespondCall(respond);
|
||||
expect(response?.[0]).toBe(false);
|
||||
expect(response?.[2]?.message).toContain(
|
||||
"unsupported channel: definitely-not-a-real-channel-xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-picks the single configured channel for send", async () => {
|
||||
mockDeliverySuccess("m-single-send");
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { sendDurableMessageBatch } from "../../channels/message/runtime.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js";
|
||||
import { createOutboundSendDeps } from "../../cli/deps.js";
|
||||
import {
|
||||
@@ -48,7 +49,6 @@ import {
|
||||
normalizeSessionKeyPreservingOpaquePeerIds,
|
||||
parseThreadSessionSuffix,
|
||||
} from "../../sessions/session-key-utils.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { ADMIN_SCOPE } from "../operator-scopes.js";
|
||||
import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
@@ -177,16 +177,17 @@ async function resolveRequestedChannel(params: {
|
||||
}
|
||||
> {
|
||||
const channelInput = readStringValue(params.requestChannel);
|
||||
const normalizedChannel = channelInput ? normalizeMessageChannel(channelInput) : undefined;
|
||||
if (params.rejectWebchatAsInternalOnly && normalizedChannel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return {
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
|
||||
),
|
||||
};
|
||||
}
|
||||
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
|
||||
if (channelInput && !normalizedChannel) {
|
||||
const normalizedInput = normalizeOptionalLowercaseString(channelInput) ?? "";
|
||||
if (params.rejectWebchatAsInternalOnly && normalizedInput === "webchat") {
|
||||
return {
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, params.unsupportedMessage(channelInput)),
|
||||
};
|
||||
|
||||
@@ -5,34 +5,8 @@ export function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
// Transcript readers repeatedly extract a fixed set of metadata fields from
|
||||
// oversized JSONL prefixes. Keep the compiled regexes process-local instead of
|
||||
// rebuilding them for every field on every oversized record.
|
||||
const TRANSCRIPT_FIELD_REGEX_CACHE = new Map<
|
||||
string,
|
||||
{ stringRe: RegExp; nullRe: RegExp; numberRe: RegExp }
|
||||
>();
|
||||
|
||||
function getTranscriptFieldRegexes(field: string): {
|
||||
stringRe: RegExp;
|
||||
nullRe: RegExp;
|
||||
numberRe: RegExp;
|
||||
} {
|
||||
let cached = TRANSCRIPT_FIELD_REGEX_CACHE.get(field);
|
||||
if (!cached) {
|
||||
const escapedField = escapeRegExp(field);
|
||||
cached = {
|
||||
stringRe: new RegExp(`"${escapedField}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`),
|
||||
nullRe: new RegExp(`"${escapedField}"\\s*:\\s*null`),
|
||||
numberRe: new RegExp(`"${escapedField}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`),
|
||||
};
|
||||
TRANSCRIPT_FIELD_REGEX_CACHE.set(field, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function extractJsonStringFieldPrefix(prefix: string, field: string): string | undefined {
|
||||
const match = getTranscriptFieldRegexes(field).stringRe.exec(prefix);
|
||||
const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -48,14 +22,16 @@ export function extractJsonNullableStringFieldPrefix(
|
||||
prefix: string,
|
||||
field: string,
|
||||
): string | null | undefined {
|
||||
if (getTranscriptFieldRegexes(field).nullRe.test(prefix)) {
|
||||
if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) {
|
||||
return null;
|
||||
}
|
||||
return extractJsonStringFieldPrefix(prefix, field);
|
||||
}
|
||||
|
||||
export function extractJsonNumberFieldPrefix(prefix: string, field: string): number | undefined {
|
||||
const match = getTranscriptFieldRegexes(field).numberRe.exec(prefix);
|
||||
const match = new RegExp(
|
||||
`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`,
|
||||
).exec(prefix);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Tests OpenClaw execution environment construction.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import {
|
||||
ensureOpenClawExecMarkerOnProcess,
|
||||
markOpenClawExecEnv,
|
||||
@@ -39,16 +38,16 @@ describe("ensureOpenClawExecMarkerOnProcess", () => {
|
||||
|
||||
it("defaults to mutating process.env when no env object is provided", () => {
|
||||
const previous = process.env[OPENCLAW_CLI_ENV_VAR];
|
||||
deleteTestEnvValue(OPENCLAW_CLI_ENV_VAR);
|
||||
delete process.env[OPENCLAW_CLI_ENV_VAR];
|
||||
|
||||
try {
|
||||
expect(ensureOpenClawExecMarkerOnProcess()).toBe(process.env);
|
||||
expect(process.env[OPENCLAW_CLI_ENV_VAR]).toBe(OPENCLAW_CLI_ENV_VALUE);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
deleteTestEnvValue(OPENCLAW_CLI_ENV_VAR);
|
||||
delete process.env[OPENCLAW_CLI_ENV_VAR];
|
||||
} else {
|
||||
setTestEnvValue(OPENCLAW_CLI_ENV_VAR, previous);
|
||||
process.env[OPENCLAW_CLI_ENV_VAR] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,15 +4,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutboundChannelPlugin: vi.fn<() => unknown>(() => null),
|
||||
resolveChannelTarget: vi.fn<() => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
target: {
|
||||
to: "+1999",
|
||||
kind: "group",
|
||||
source: "normalized",
|
||||
resolutionSource: "normalized",
|
||||
},
|
||||
})),
|
||||
resolveOutboundTarget: vi.fn<() => { ok: true; to: string } | { ok: false; error: Error }>(
|
||||
() => ({ ok: true, to: "+1999" }),
|
||||
),
|
||||
@@ -91,16 +82,11 @@ vi.mock("./outbound-session.js", () => ({
|
||||
resolveOutboundSessionRoute: mocks.resolveOutboundSessionRoute,
|
||||
}));
|
||||
|
||||
vi.mock("./target-resolver.js", () => ({
|
||||
resolveChannelTarget: mocks.resolveChannelTarget,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/message-channel.js", () => ({
|
||||
INTERNAL_MESSAGE_CHANNEL: "webchat",
|
||||
isDeliverableMessageChannel: (channel: string) =>
|
||||
["directchat", "workspace", "telegram"].includes(channel),
|
||||
isDeliverableMessageChannel: (channel: string) => ["directchat", "workspace"].includes(channel),
|
||||
isGatewayMessageChannel: (channel: string) =>
|
||||
["directchat", "workspace", "telegram", "webchat"].includes(channel),
|
||||
["directchat", "workspace", "webchat"].includes(channel),
|
||||
normalizeMessageChannel: (value: string) => value.trim().toLowerCase(),
|
||||
}));
|
||||
|
||||
@@ -120,18 +106,7 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReset();
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue(null);
|
||||
mocks.resolveChannelTarget.mockReset();
|
||||
mocks.resolveChannelTarget.mockResolvedValue({
|
||||
ok: true,
|
||||
target: {
|
||||
to: "+1999",
|
||||
kind: "group",
|
||||
source: "normalized",
|
||||
resolutionSource: "normalized",
|
||||
},
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReset();
|
||||
mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "+1999" });
|
||||
mocks.resolveOutboundTarget.mockClear();
|
||||
mocks.resolveOutboundSessionRoute.mockReset();
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValue(null);
|
||||
mocks.resolveSessionDeliveryTarget.mockClear();
|
||||
@@ -338,181 +313,6 @@ describe("agent delivery helpers", () => {
|
||||
expect(plan.resolvedTo).toBe("1470130713209602050");
|
||||
});
|
||||
|
||||
it("resolves reserved explicit targets through directory-capable resolution before session routing", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: false,
|
||||
error: new Error('Reserved target "current" for Telegram'),
|
||||
});
|
||||
mocks.resolveChannelTarget.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
target: {
|
||||
to: "telegram:-1002458651455",
|
||||
kind: "group",
|
||||
source: "directory",
|
||||
resolutionSource: "directory",
|
||||
},
|
||||
});
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce({
|
||||
sessionKey: "agent:telegram:group:-1002458651455",
|
||||
baseSessionKey: "agent:telegram:group:-1002458651455",
|
||||
peer: { kind: "group", id: "-1002458651455" },
|
||||
chatType: "group",
|
||||
from: "telegram:group:-1002458651455",
|
||||
to: "telegram:-1002458651455",
|
||||
});
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
currentSessionKey: "agent:main",
|
||||
sessionEntry: undefined,
|
||||
requestedChannel: "telegram",
|
||||
explicitTo: "current",
|
||||
accountId: "work",
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(mocks.resolveChannelTarget).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
input: "current",
|
||||
accountId: "work",
|
||||
unknownTargetMode: "normalized",
|
||||
plugin: {
|
||||
messaging: { resolveOutboundSessionRoute: expect.any(Function), targetResolver: {} },
|
||||
},
|
||||
});
|
||||
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
agentId: "agent",
|
||||
accountId: "work",
|
||||
target: "telegram:-1002458651455",
|
||||
resolvedTarget: {
|
||||
to: "telegram:-1002458651455",
|
||||
kind: "group",
|
||||
source: "directory",
|
||||
resolutionSource: "directory",
|
||||
},
|
||||
currentSessionKey: "agent:main",
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(plan.resolvedTo).toBe("telegram:-1002458651455");
|
||||
expect(plan.targetResolutionError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps reserved explicit target errors when directory-capable resolution misses", async () => {
|
||||
const reservedError = new Error('Reserved target "current" for Telegram');
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: false,
|
||||
error: reservedError,
|
||||
});
|
||||
mocks.resolveChannelTarget.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: reservedError,
|
||||
});
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
sessionEntry: undefined,
|
||||
requestedChannel: "telegram",
|
||||
explicitTo: "current",
|
||||
accountId: undefined,
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(mocks.resolveChannelTarget).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
input: "current",
|
||||
accountId: undefined,
|
||||
unknownTargetMode: "normalized",
|
||||
plugin: {
|
||||
messaging: { resolveOutboundSessionRoute: expect.any(Function), targetResolver: {} },
|
||||
},
|
||||
});
|
||||
expect(mocks.resolveOutboundSessionRoute).not.toHaveBeenCalled();
|
||||
expect(plan.resolvedTo).toBe("current");
|
||||
expect(plan.targetResolutionError).toBe(reservedError);
|
||||
});
|
||||
|
||||
it("keeps directory-resolved reserved explicit targets when session-route canonicalization misses", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn(), targetResolver: {} },
|
||||
});
|
||||
mocks.resolveOutboundTarget.mockReturnValueOnce({
|
||||
ok: false,
|
||||
error: new Error('Reserved target "current" for Telegram'),
|
||||
});
|
||||
mocks.resolveChannelTarget.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
target: {
|
||||
to: "telegram:-1002458651455",
|
||||
kind: "group",
|
||||
source: "directory",
|
||||
resolutionSource: "directory",
|
||||
},
|
||||
});
|
||||
mocks.resolveOutboundSessionRoute.mockResolvedValueOnce(null);
|
||||
|
||||
const plan = await resolveAgentDeliveryPlanWithSessionRoute({
|
||||
cfg: {} as OpenClawConfig,
|
||||
agentId: "agent",
|
||||
currentSessionKey: "agent:main",
|
||||
sessionEntry: undefined,
|
||||
requestedChannel: "telegram",
|
||||
explicitTo: "current",
|
||||
accountId: "work",
|
||||
wantsDelivery: true,
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundSessionRoute).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
channel: "telegram",
|
||||
agentId: "agent",
|
||||
accountId: "work",
|
||||
target: "telegram:-1002458651455",
|
||||
resolvedTarget: {
|
||||
to: "telegram:-1002458651455",
|
||||
kind: "group",
|
||||
source: "directory",
|
||||
resolutionSource: "directory",
|
||||
},
|
||||
currentSessionKey: "agent:main",
|
||||
threadId: undefined,
|
||||
});
|
||||
expect(plan.resolvedTo).toBe("telegram:-1002458651455");
|
||||
expect(plan.targetResolutionError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces stored explicit target errors even when explicit validation is disabled", () => {
|
||||
const targetResolutionError = new Error('reserved target "current"');
|
||||
|
||||
const resolved = resolveAgentOutboundTarget({
|
||||
cfg: {} as OpenClawConfig,
|
||||
plan: {
|
||||
baseDelivery: { mode: "explicit" },
|
||||
resolvedChannel: "workspace",
|
||||
resolvedTo: "current",
|
||||
deliveryTargetMode: "explicit",
|
||||
targetResolutionError,
|
||||
},
|
||||
targetMode: "explicit",
|
||||
validateExplicitTarget: false,
|
||||
});
|
||||
|
||||
expect(mocks.resolveOutboundTarget).not.toHaveBeenCalled();
|
||||
expect(resolved.resolvedTarget).toEqual({ ok: false, error: targetResolutionError });
|
||||
expect(resolved.resolvedTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the original plan when session-route canonicalization fails", async () => {
|
||||
mocks.resolveOutboundChannelPlugin.mockReturnValue({
|
||||
messaging: { resolveOutboundSessionRoute: vi.fn() },
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
|
||||
import { resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
import { isReservedTargetLiteralError } from "./target-errors.js";
|
||||
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
import type { OutboundTargetResolution } from "./targets.js";
|
||||
import {
|
||||
resolveOutboundTarget,
|
||||
@@ -31,7 +29,6 @@ export type AgentDeliveryPlan = {
|
||||
resolvedAccountId?: string;
|
||||
resolvedThreadId?: string | number;
|
||||
deliveryTargetMode?: ChannelOutboundTargetMode;
|
||||
targetResolutionError?: Error;
|
||||
};
|
||||
|
||||
export function resolveAgentDeliveryPlan(params: {
|
||||
@@ -146,46 +143,27 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
|
||||
},
|
||||
): Promise<AgentDeliveryPlan> {
|
||||
const plan = resolveAgentDeliveryPlan(params);
|
||||
const { resolvedChannel, resolvedTo } = plan;
|
||||
if (!params.wantsDelivery || !resolvedTo || !isDeliverableMessageChannel(resolvedChannel)) {
|
||||
return plan;
|
||||
}
|
||||
const plugin = resolveOutboundChannelPlugin({
|
||||
channel: resolvedChannel,
|
||||
cfg: params.cfg,
|
||||
allowBootstrap: true,
|
||||
});
|
||||
if (!plugin?.messaging?.resolveOutboundSessionRoute) {
|
||||
if (
|
||||
!params.wantsDelivery ||
|
||||
!plan.resolvedTo ||
|
||||
!isDeliverableMessageChannel(plan.resolvedChannel) ||
|
||||
!resolveOutboundChannelPlugin({
|
||||
channel: plan.resolvedChannel,
|
||||
cfg: params.cfg,
|
||||
allowBootstrap: true,
|
||||
})?.messaging?.resolveOutboundSessionRoute
|
||||
) {
|
||||
return plan;
|
||||
}
|
||||
const normalizedTarget = resolveOutboundTarget({
|
||||
channel: resolvedChannel,
|
||||
to: resolvedTo,
|
||||
channel: plan.resolvedChannel,
|
||||
to: plan.resolvedTo,
|
||||
cfg: params.cfg,
|
||||
accountId: plan.resolvedAccountId,
|
||||
mode: plan.deliveryTargetMode ?? "explicit",
|
||||
});
|
||||
let sessionRouteTarget: string;
|
||||
let resolvedSessionRouteTarget: ResolvedMessagingTarget | undefined;
|
||||
if (normalizedTarget.ok) {
|
||||
sessionRouteTarget = normalizedTarget.to;
|
||||
} else {
|
||||
if (!isReservedTargetLiteralError(normalizedTarget.error)) {
|
||||
return { ...plan, targetResolutionError: normalizedTarget.error };
|
||||
}
|
||||
const resolvedTarget = await resolveChannelTarget({
|
||||
cfg: params.cfg,
|
||||
channel: resolvedChannel as ChannelId,
|
||||
input: resolvedTo,
|
||||
accountId: plan.resolvedAccountId,
|
||||
unknownTargetMode: "normalized",
|
||||
plugin,
|
||||
});
|
||||
if (!resolvedTarget.ok) {
|
||||
return { ...plan, targetResolutionError: resolvedTarget.error };
|
||||
}
|
||||
sessionRouteTarget = resolvedTarget.target.to;
|
||||
resolvedSessionRouteTarget = resolvedTarget.target;
|
||||
if (!normalizedTarget.ok) {
|
||||
return plan;
|
||||
}
|
||||
const explicitThreadId =
|
||||
params.explicitThreadId != null && params.explicitThreadId !== ""
|
||||
@@ -195,11 +173,10 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
|
||||
try {
|
||||
return await resolveOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
channel: resolvedChannel as ChannelId,
|
||||
channel: plan.resolvedChannel as ChannelId,
|
||||
agentId: params.agentId,
|
||||
accountId: plan.resolvedAccountId,
|
||||
target: sessionRouteTarget,
|
||||
...(resolvedSessionRouteTarget ? { resolvedTarget: resolvedSessionRouteTarget } : {}),
|
||||
target: normalizedTarget.to,
|
||||
currentSessionKey: params.currentSessionKey,
|
||||
threadId: plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
|
||||
});
|
||||
@@ -208,14 +185,6 @@ export async function resolveAgentDeliveryPlanWithSessionRoute(
|
||||
}
|
||||
})();
|
||||
if (!route) {
|
||||
if (resolvedSessionRouteTarget) {
|
||||
return {
|
||||
...plan,
|
||||
resolvedTo: resolvedSessionRouteTarget.to,
|
||||
resolvedThreadId:
|
||||
plan.deliveryTargetMode === "explicit" ? explicitThreadId : plan.resolvedThreadId,
|
||||
};
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
return {
|
||||
@@ -241,13 +210,6 @@ export function resolveAgentOutboundTarget(params: {
|
||||
params.targetMode ??
|
||||
params.plan.deliveryTargetMode ??
|
||||
(params.plan.resolvedTo ? "explicit" : "implicit");
|
||||
if (params.plan.targetResolutionError) {
|
||||
return {
|
||||
resolvedTarget: { ok: false, error: params.plan.targetResolutionError },
|
||||
resolvedTo: undefined,
|
||||
targetMode,
|
||||
};
|
||||
}
|
||||
if (!isDeliverableMessageChannel(params.plan.resolvedChannel)) {
|
||||
return {
|
||||
resolvedTarget: null,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeAll, beforeEach } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { drainPendingDeliveries, type DeliverFn, loadPendingDeliveries } from "./delivery-queue.js";
|
||||
import {
|
||||
createRecoveryLog,
|
||||
installDeliveryQueueTmpDirHooks,
|
||||
} from "./delivery-queue.test-helpers.js";
|
||||
|
||||
let deliverOutboundPayloads: typeof import("./deliver.js").deliverOutboundPayloads;
|
||||
|
||||
async function drainMatrixReconnect(opts: { deliver: DeliverFn; stateDir: string }): Promise<void> {
|
||||
await drainPendingDeliveries({
|
||||
drainKey: "matrix:reconnect-test",
|
||||
logLabel: "Matrix reconnect drain",
|
||||
cfg: {} as OpenClawConfig,
|
||||
log: createRecoveryLog(),
|
||||
stateDir: opts.stateDir,
|
||||
deliver: opts.deliver,
|
||||
selectEntry: (entry) => ({ match: entry.channel === "matrix" }),
|
||||
});
|
||||
}
|
||||
|
||||
function createPartialSendFailure() {
|
||||
return vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m1" })
|
||||
.mockRejectedValueOnce(new Error("second payload send failed"));
|
||||
}
|
||||
|
||||
async function deliverPartialMatrixBatch(sendMatrix: ReturnType<typeof vi.fn>, tmpDir: string) {
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "matrix",
|
||||
to: "!room:example",
|
||||
payloads: [{ text: "first" }, { text: "second" }],
|
||||
deps: { matrix: sendMatrix },
|
||||
queuePolicy: "required",
|
||||
}),
|
||||
).rejects.toThrow("second payload send failed");
|
||||
}
|
||||
|
||||
describe("deliverOutboundPayloads queue integration: mid-batch failure with send evidence", () => {
|
||||
const fixtures = installDeliveryQueueTmpDirHooks();
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ deliverOutboundPayloads } = await import("./deliver.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fixtures.tmpDir();
|
||||
});
|
||||
|
||||
it("advances queued entry to unknown_after_send when a later payload fails after an earlier one succeeded", async () => {
|
||||
const sendMatrix = createPartialSendFailure();
|
||||
|
||||
await deliverPartialMatrixBatch(sendMatrix, tmpDir);
|
||||
|
||||
const entries = await loadPendingDeliveries(tmpDir);
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0];
|
||||
expect(entry.recoveryState).toBe("unknown_after_send");
|
||||
expect(entry.retryCount).toBe(0);
|
||||
expect(entry.lastError).toBeUndefined();
|
||||
expect(sendMatrix).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drain does not replay an unknown_after_send entry when no adapter reconciliation is available", async () => {
|
||||
const sendMatrix = createPartialSendFailure();
|
||||
|
||||
await deliverPartialMatrixBatch(sendMatrix, tmpDir);
|
||||
|
||||
const beforeDrain = await loadPendingDeliveries(tmpDir);
|
||||
expect(beforeDrain[0]?.recoveryState).toBe("unknown_after_send");
|
||||
|
||||
const deliver = vi.fn<DeliverFn>(async () => {});
|
||||
await drainMatrixReconnect({ deliver, stateDir: tmpDir });
|
||||
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
expect(await loadPendingDeliveries(tmpDir)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves entry for retry in send_attempt_started when no send evidence exists", async () => {
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed"));
|
||||
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "matrix",
|
||||
to: "!room:example",
|
||||
payloads: [{ text: "first" }],
|
||||
deps: { matrix: sendMatrix },
|
||||
queuePolicy: "required",
|
||||
}),
|
||||
).rejects.toThrow("first payload send failed");
|
||||
|
||||
const entries = await import("./delivery-queue.js").then((m) =>
|
||||
m.loadPendingDeliveries(tmpDir),
|
||||
);
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0];
|
||||
expect(entry.retryCount).toBe(1);
|
||||
expect(entry.recoveryState).toBe("send_attempt_started");
|
||||
expect(entry.lastError).toContain("first payload send failed");
|
||||
});
|
||||
});
|
||||
@@ -1045,51 +1045,6 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks queued delivery as unknown-after-send (not failed) when a later payload fails after an earlier one succeeded", async () => {
|
||||
const sendMatrix = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "m1" })
|
||||
.mockRejectedValueOnce(new Error("second payload send failed"));
|
||||
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:example",
|
||||
payloads: [{ text: "first" }, { text: "second" }],
|
||||
deps: { matrix: sendMatrix },
|
||||
queuePolicy: "required",
|
||||
}),
|
||||
).rejects.toThrow("second payload send failed");
|
||||
|
||||
expect(sendMatrix).toHaveBeenCalledTimes(2);
|
||||
expect(queueMocks.markDeliveryPlatformOutcomeUnknown).toHaveBeenCalledWith("mock-queue-id");
|
||||
expect(queueMocks.failDelivery).not.toHaveBeenCalled();
|
||||
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still calls failDelivery when a payload fails before any send succeeded", async () => {
|
||||
const sendMatrix = vi.fn().mockRejectedValueOnce(new Error("first payload send failed"));
|
||||
|
||||
await expect(
|
||||
deliverOutboundPayloads({
|
||||
cfg: {},
|
||||
channel: "matrix",
|
||||
to: "!room:example",
|
||||
payloads: [{ text: "first" }],
|
||||
deps: { matrix: sendMatrix },
|
||||
queuePolicy: "required",
|
||||
}),
|
||||
).rejects.toThrow("first payload send failed");
|
||||
|
||||
expect(queueMocks.failDelivery).toHaveBeenCalledWith(
|
||||
"mock-queue-id",
|
||||
expect.stringContaining("first payload send failed"),
|
||||
);
|
||||
expect(queueMocks.markDeliveryPlatformOutcomeUnknown).not.toHaveBeenCalled();
|
||||
expect(queueMocks.ackDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails required delivery when the post-send unknown marker cannot be written", async () => {
|
||||
queueMocks.markDeliveryPlatformOutcomeUnknown.mockRejectedValueOnce(
|
||||
new Error("unknown marker offline"),
|
||||
|
||||
@@ -1326,9 +1326,9 @@ async function deliverOutboundPayloadsWithQueueCleanup(
|
||||
};
|
||||
const queuePolicy = params.queuePolicy ?? "best_effort";
|
||||
let platformResultsReturned = false;
|
||||
let platformSendStarted = false;
|
||||
|
||||
try {
|
||||
let platformSendStarted = false;
|
||||
const results = await deliverOutboundPayloadsCore({
|
||||
...wrappedParams,
|
||||
...(queueId
|
||||
@@ -1390,29 +1390,11 @@ async function deliverOutboundPayloadsWithQueueCleanup(
|
||||
if (isDeliveryAbortError(err)) {
|
||||
await ackDelivery(queueId).catch(() => {});
|
||||
} else if (!platformResultsReturned) {
|
||||
const sendEvidence =
|
||||
platformSendStarted && err instanceof OutboundDeliveryError && err.sentBeforeError;
|
||||
if (sendEvidence) {
|
||||
await markQueuedPlatformOutcomeUnknown({
|
||||
queueId,
|
||||
queuePolicy,
|
||||
}).catch((markErr: unknown) => {
|
||||
log.warn(
|
||||
`failed to mark queued delivery ${queueId} as platform-outcome-unknown after mid-send error; falling back to fail: ${formatErrorMessage(markErr)}`,
|
||||
);
|
||||
return failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => {
|
||||
log.warn(
|
||||
`failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => {
|
||||
log.warn(
|
||||
`failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
await failDelivery(queueId, formatErrorMessage(err)).catch((failErr: unknown) => {
|
||||
log.warn(
|
||||
`failed to mark queued delivery ${queueId} as failed: ${formatErrorMessage(failErr)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -389,19 +389,15 @@ async function drainQueuedEntry(opts: {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
const reconciliationProvedPreSendFailure =
|
||||
reconciliation?.status === "not_sent" && entry.recoveryState === "send_attempt_started";
|
||||
if (reconciliationProvedPreSendFailure) {
|
||||
if (reconciliation?.status === "not_sent") {
|
||||
opts.log.info(
|
||||
`Delivery entry ${entry.id} reconciled ${entry.recoveryState} as not sent; replaying`,
|
||||
);
|
||||
} else {
|
||||
let errMsg = `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`;
|
||||
if (reconciliation?.status === "not_sent") {
|
||||
errMsg = `delivery state is ${entry.recoveryState}; refusing full replay after post-send evidence`;
|
||||
} else if (reconciliation?.status === "unresolved" && reconciliation.error) {
|
||||
errMsg = `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`;
|
||||
}
|
||||
const errMsg =
|
||||
reconciliation?.status === "unresolved" && reconciliation.error
|
||||
? `delivery state is ${entry.recoveryState} and reconciliation is unresolved: ${reconciliation.error}`
|
||||
: `delivery state is ${entry.recoveryState}; refusing blind replay without adapter reconciliation`;
|
||||
opts.log.warn(`Delivery entry ${entry.id} ${errMsg}`);
|
||||
opts.onFailed?.(entry, errMsg);
|
||||
if (reconciliation?.status === "unresolved" && reconciliation.retryable === true) {
|
||||
|
||||
@@ -328,7 +328,7 @@ describe("delivery-queue recovery", () => {
|
||||
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("moves unknown-after-send entries to failed when adapter reports not sent", async () => {
|
||||
it("replays unknown-after-send entries only after adapter proves they were not sent", async () => {
|
||||
const id = await enqueueDelivery(
|
||||
{ channel: "demo-channel-a", to: "+1", payloads: [{ text: "not sent" }] },
|
||||
tmpDir(),
|
||||
@@ -346,19 +346,24 @@ describe("delivery-queue recovery", () => {
|
||||
});
|
||||
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const log = createRecoveryLog();
|
||||
const { result } = await runRecovery({ deliver, log });
|
||||
const { result } = await runRecovery({ deliver });
|
||||
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
const deliverInput = mockCallArg(deliver) as {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
skipQueue?: boolean;
|
||||
};
|
||||
expect(deliverInput.channel).toBe("demo-channel-a");
|
||||
expect(deliverInput.to).toBe("+1");
|
||||
expect(deliverInput.skipQueue).toBe(true);
|
||||
expect(result).toEqual({
|
||||
recovered: 0,
|
||||
failed: 1,
|
||||
recovered: 1,
|
||||
failed: 0,
|
||||
skippedMaxRetries: 0,
|
||||
deferredBackoff: 0,
|
||||
});
|
||||
expect(await loadPendingDeliveries(tmpDir())).toHaveLength(0);
|
||||
expect(readOutboundQueueStatus(tmpDir(), id)).toBe("failed");
|
||||
expectMockMessageContaining(log.warn, "refusing full replay after post-send evidence");
|
||||
});
|
||||
|
||||
it("keeps retryable unresolved unknown-after-send entries on the queue without replaying", async () => {
|
||||
|
||||
@@ -3,10 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ambiguousTargetError,
|
||||
ambiguousTargetMessage,
|
||||
isReservedTargetLiteralError,
|
||||
missingTargetError,
|
||||
missingTargetMessage,
|
||||
reservedTargetLiteralError,
|
||||
unknownTargetError,
|
||||
unknownTargetMessage,
|
||||
} from "./target-errors.js";
|
||||
@@ -71,13 +69,4 @@ describe("target error helpers", () => {
|
||||
"Hint: Use channel:123",
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies reserved target literal errors", () => {
|
||||
expect(isReservedTargetLiteralError(reservedTargetLiteralError("Telegram", "current"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isReservedTargetLiteralError(new Error('Unknown target "current" for Telegram.'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,18 +40,6 @@ export function unknownTargetError(provider: string, raw: string, hint?: string)
|
||||
return new Error(unknownTargetMessage(provider, raw, hint));
|
||||
}
|
||||
|
||||
export function reservedTargetLiteralMessage(provider: string, raw: string, hint?: string): string {
|
||||
return `Reserved target "${raw}" for ${provider} cannot be used as a literal destination. Provide an explicit id or handle.${formatTargetHint(hint, true)}`;
|
||||
}
|
||||
|
||||
export function reservedTargetLiteralError(provider: string, raw: string, hint?: string): Error {
|
||||
return new Error(reservedTargetLiteralMessage(provider, raw, hint));
|
||||
}
|
||||
|
||||
export function isReservedTargetLiteralError(error: Error): boolean {
|
||||
return error.message.includes("Reserved target");
|
||||
}
|
||||
|
||||
function formatTargetHint(hint?: string, withLabel = false): string {
|
||||
const normalized = hint?.trim();
|
||||
if (!normalized) {
|
||||
|
||||
@@ -32,52 +32,6 @@ function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin
|
||||
return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId);
|
||||
}
|
||||
|
||||
function normalizeTargetLiteral(value: string): string | undefined {
|
||||
return normalizeOptionalLowercaseString(value);
|
||||
}
|
||||
|
||||
function stripPluginTargetPrefix(raw: string, plugin: ChannelPlugin): string {
|
||||
let target = raw.trim();
|
||||
const prefixes = [plugin.id, ...(plugin.messaging?.targetPrefixes ?? [])]
|
||||
.map((prefix) => normalizeTargetLiteral(String(prefix)))
|
||||
.filter((prefix): prefix is string => Boolean(prefix));
|
||||
while (target) {
|
||||
const lowered = normalizeTargetLiteral(target) ?? "";
|
||||
const prefix = prefixes.find((candidate) => lowered.startsWith(`${candidate}:`));
|
||||
if (!prefix) {
|
||||
return target;
|
||||
}
|
||||
target = target.slice(prefix.length + 1).trim();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function resolveReservedTargetLiteral(params: {
|
||||
raw?: string;
|
||||
plugin?: ChannelPlugin;
|
||||
}): string | undefined {
|
||||
const raw = normalizeOptionalString(params.raw);
|
||||
const plugin = params.plugin;
|
||||
const reservedLiterals = plugin?.messaging?.targetResolver?.reservedLiterals;
|
||||
if (!raw || !plugin || !reservedLiterals?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const stripped = stripPluginTargetPrefix(raw, plugin);
|
||||
if (!stripped || /^[@#]/.test(stripped) || /^(channel|group|user):/i.test(stripped)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeTargetLiteral(stripped);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const reserved = new Set(
|
||||
reservedLiterals
|
||||
.map(normalizeTargetLiteral)
|
||||
.filter((literal): literal is string => Boolean(literal)),
|
||||
);
|
||||
return reserved.has(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resetTargetNormalizerCacheForTests(): void {
|
||||
targetNormalizerCacheByChannelId.clear();
|
||||
}
|
||||
@@ -270,15 +224,10 @@ export function buildTargetResolverSignature(
|
||||
: "pinned";
|
||||
const resolver = plugin?.messaging?.targetResolver;
|
||||
const hint = resolver?.hint ?? "";
|
||||
const reserved = (resolver?.reservedLiterals ?? [])
|
||||
.map(normalizeTargetLiteral)
|
||||
.filter((literal): literal is string => Boolean(literal))
|
||||
.toSorted()
|
||||
.join(",");
|
||||
const looksLike = resolver?.looksLikeId;
|
||||
// Function source is only a cheap invalidation hint; resolver behavior still belongs to the plugin.
|
||||
const source = looksLike ? looksLike.toString() : "";
|
||||
return hashSignature(`${registryScope}|${hint}|${reserved}|${source}`);
|
||||
return hashSignature(`${registryScope}|${hint}|${source}`);
|
||||
}
|
||||
|
||||
function hashSignature(value: string): string {
|
||||
|
||||
@@ -130,206 +130,6 @@ describe("resolveMessagingTarget (directory fallback)", () => {
|
||||
expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves configured directory entries before rejecting reserved literal targets", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
...createChannelTestPluginBase({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
}),
|
||||
directory: {
|
||||
listPeers: mocks.listPeers,
|
||||
listPeersLive: mocks.listPeersLive,
|
||||
listGroups: mocks.listGroups,
|
||||
listGroupsLive: mocks.listGroupsLive,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
resolveTarget: mocks.resolveTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.listGroups.mockResolvedValue([
|
||||
{
|
||||
kind: "group",
|
||||
id: "-1002458651455",
|
||||
name: "Current x jerry Channel",
|
||||
handle: "@current",
|
||||
} satisfies ChannelDirectoryEntry,
|
||||
]);
|
||||
|
||||
const result = await resolveMessagingTarget({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
input: "current",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.target.to).toBe("-1002458651455");
|
||||
expect(result.target.source).toBe("directory");
|
||||
}
|
||||
expect(mocks.listGroups).toHaveBeenCalled();
|
||||
expect(mocks.resolveTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps reserved literals on the directory path before id-like plugin normalization", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
...createChannelTestPluginBase({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
}),
|
||||
directory: {
|
||||
listPeers: mocks.listPeers,
|
||||
listPeersLive: mocks.listPeersLive,
|
||||
listGroups: mocks.listGroups,
|
||||
listGroupsLive: mocks.listGroupsLive,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (raw: string) =>
|
||||
raw === "current" || raw === "telegram:current" ? "telegram:@current" : raw,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw: string) => raw === "current" || raw === "telegram:current",
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
resolveTarget: mocks.resolveTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.listGroups.mockResolvedValueOnce([
|
||||
{ kind: "group", id: "room-1", name: "current" } satisfies ChannelDirectoryEntry,
|
||||
]);
|
||||
|
||||
const hit = await resolveMessagingTarget({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
input: "current",
|
||||
});
|
||||
|
||||
expect(hit.ok).toBe(true);
|
||||
if (hit.ok) {
|
||||
expect(hit.target.to).toBe("room-1");
|
||||
expect(hit.target.source).toBe("directory");
|
||||
}
|
||||
expect(mocks.resolveTarget).not.toHaveBeenCalled();
|
||||
|
||||
resetDirectoryCache();
|
||||
mocks.listGroups.mockResolvedValueOnce([
|
||||
{ kind: "group", id: "room-1", name: "current" } satisfies ChannelDirectoryEntry,
|
||||
]);
|
||||
|
||||
const prefixedHit = await resolveMessagingTarget({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
input: "telegram:current",
|
||||
});
|
||||
|
||||
expect(prefixedHit.ok).toBe(true);
|
||||
if (prefixedHit.ok) {
|
||||
expect(prefixedHit.target.to).toBe("room-1");
|
||||
expect(prefixedHit.target.source).toBe("directory");
|
||||
}
|
||||
|
||||
resetDirectoryCache();
|
||||
mocks.listGroups.mockResolvedValueOnce([]);
|
||||
mocks.listGroupsLive.mockResolvedValueOnce([]);
|
||||
|
||||
const miss = await resolveMessagingTarget({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
input: "current",
|
||||
});
|
||||
|
||||
expect(miss.ok).toBe(false);
|
||||
if (!miss.ok) {
|
||||
expect(miss.error.message).toContain('Reserved target "current"');
|
||||
expect(miss.error.message).toContain("Telegram");
|
||||
}
|
||||
expect(mocks.resolveTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects reserved literal targets after directory miss", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
...createChannelTestPluginBase({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
}),
|
||||
directory: {
|
||||
listPeers: mocks.listPeers,
|
||||
listPeersLive: mocks.listPeersLive,
|
||||
listGroups: mocks.listGroups,
|
||||
listGroupsLive: mocks.listGroupsLive,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
resolveTarget: mocks.resolveTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.listGroups.mockResolvedValue([]);
|
||||
mocks.listGroupsLive.mockResolvedValue([]);
|
||||
|
||||
const result = await resolveMessagingTarget({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
input: "current",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Reserved target "current"');
|
||||
expect(result.error.message).toContain("Telegram");
|
||||
}
|
||||
expect(mocks.listGroups).toHaveBeenCalled();
|
||||
expect(mocks.resolveTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires exact directory matches before preserving reserved literal targets", async () => {
|
||||
mocks.getChannelPlugin.mockReturnValue({
|
||||
...createChannelTestPluginBase({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
capabilities: { chatTypes: ["direct", "group", "channel"] },
|
||||
}),
|
||||
directory: {
|
||||
listPeers: mocks.listPeers,
|
||||
listPeersLive: mocks.listPeersLive,
|
||||
listGroups: mocks.listGroups,
|
||||
listGroupsLive: mocks.listGroupsLive,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
resolveTarget: mocks.resolveTarget,
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.listGroups.mockResolvedValue([
|
||||
{ kind: "group", id: "memes-room", name: "memes" } satisfies ChannelDirectoryEntry,
|
||||
]);
|
||||
mocks.listGroupsLive.mockResolvedValue([]);
|
||||
|
||||
const result = await resolveMessagingTarget({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
input: "me",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain('Reserved target "me"');
|
||||
expect(result.error.message).toContain("Telegram");
|
||||
}
|
||||
expect(mocks.resolveTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse directory cache entries across prepared plugin runtimes", async () => {
|
||||
const firstListGroups = vi
|
||||
.fn()
|
||||
|
||||
@@ -11,11 +11,7 @@ import type {
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { buildDirectoryCacheKey, DirectoryCache } from "./directory-cache.js";
|
||||
import {
|
||||
ambiguousTargetError,
|
||||
reservedTargetLiteralError,
|
||||
unknownTargetError,
|
||||
} from "./target-errors.js";
|
||||
import { ambiguousTargetError, unknownTargetError } from "./target-errors.js";
|
||||
import { maybeResolveIdLikeTarget, type ResolvedIdLikeTarget } from "./target-id-resolution.js";
|
||||
import {
|
||||
buildTargetResolverSignature,
|
||||
@@ -24,7 +20,6 @@ import {
|
||||
normalizeChannelTargetInput,
|
||||
normalizeTargetForProvider,
|
||||
resolveNormalizedTargetInput,
|
||||
resolveReservedTargetLiteral,
|
||||
} from "./target-normalization.js";
|
||||
|
||||
/** Directory-backed destination kind used by outbound target resolution. */
|
||||
@@ -96,21 +91,9 @@ function normalizeQuery(value: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(value);
|
||||
}
|
||||
|
||||
function stripTargetPrefixes(value: string, channel?: ChannelId, plugin?: ChannelPlugin): string {
|
||||
const providerPrefixes = [channel, plugin?.id, ...(plugin?.messaging?.targetPrefixes ?? [])]
|
||||
.map((prefix) => prefix?.trim().toLowerCase() ?? "")
|
||||
.filter(Boolean);
|
||||
let target = value.trim();
|
||||
while (target) {
|
||||
const lowered = target.toLowerCase();
|
||||
const prefix = providerPrefixes.find((candidate) => lowered.startsWith(`${candidate}:`));
|
||||
if (!prefix) {
|
||||
break;
|
||||
}
|
||||
target = target.slice(prefix.length + 1).trim();
|
||||
}
|
||||
return target
|
||||
.replace(/^(channel|group|user):/i, "")
|
||||
function stripTargetPrefixes(value: string): string {
|
||||
return value
|
||||
.replace(/^(channel|user):/i, "")
|
||||
.replace(/^[@#]/, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -227,7 +210,6 @@ function matchesDirectoryEntry(params: {
|
||||
entry: ChannelDirectoryEntry;
|
||||
query: string;
|
||||
plugin?: ChannelPlugin;
|
||||
exactOnly?: boolean;
|
||||
}): boolean {
|
||||
const query = normalizeQuery(params.query);
|
||||
if (!query) {
|
||||
@@ -235,19 +217,11 @@ function matchesDirectoryEntry(params: {
|
||||
}
|
||||
const id = stripTargetPrefixes(
|
||||
normalizeDirectoryEntryId(params.channel, params.entry, params.plugin),
|
||||
params.channel,
|
||||
params.plugin,
|
||||
);
|
||||
const name = params.entry.name
|
||||
? stripTargetPrefixes(params.entry.name, params.channel, params.plugin)
|
||||
: "";
|
||||
const handle = params.entry.handle
|
||||
? stripTargetPrefixes(params.entry.handle, params.channel, params.plugin)
|
||||
: "";
|
||||
const name = params.entry.name ? stripTargetPrefixes(params.entry.name) : "";
|
||||
const handle = params.entry.handle ? stripTargetPrefixes(params.entry.handle) : "";
|
||||
const candidates = [id, name, handle].map((value) => normalizeQuery(value)).filter(Boolean);
|
||||
return candidates.some((value) =>
|
||||
params.exactOnly ? value === query : value === query || value.includes(query),
|
||||
);
|
||||
return candidates.some((value) => value === query || value.includes(query));
|
||||
}
|
||||
|
||||
function resolveMatch(params: {
|
||||
@@ -255,7 +229,6 @@ function resolveMatch(params: {
|
||||
entries: ChannelDirectoryEntry[];
|
||||
query: string;
|
||||
plugin?: ChannelPlugin;
|
||||
exactOnly?: boolean;
|
||||
}) {
|
||||
const matches = params.entries.filter((entry) =>
|
||||
matchesDirectoryEntry({
|
||||
@@ -263,7 +236,6 @@ function resolveMatch(params: {
|
||||
entry,
|
||||
query: params.query,
|
||||
plugin: params.plugin,
|
||||
exactOnly: params.exactOnly,
|
||||
}),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
@@ -426,10 +398,8 @@ export async function resolveMessagingTarget(params: {
|
||||
const kind = detectTargetKind(params.channel, raw, params.preferredKind, plugin);
|
||||
const normalizedInput = resolveNormalizedTargetInput(params.channel, raw, plugin);
|
||||
const normalized = normalizedInput?.normalized ?? raw;
|
||||
const reservedLiteral = resolveReservedTargetLiteral({ raw, plugin });
|
||||
if (
|
||||
normalizedInput &&
|
||||
!reservedLiteral &&
|
||||
looksLikeTargetId({
|
||||
channel: params.channel,
|
||||
raw: normalizedInput.raw,
|
||||
@@ -456,7 +426,7 @@ export async function resolveMessagingTarget(params: {
|
||||
kind,
|
||||
});
|
||||
}
|
||||
const query = stripTargetPrefixes(raw, params.channel, plugin);
|
||||
const query = stripTargetPrefixes(raw);
|
||||
const entries = await getDirectoryEntries({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
@@ -467,13 +437,7 @@ export async function resolveMessagingTarget(params: {
|
||||
preferLiveOnMiss: true,
|
||||
plugin,
|
||||
});
|
||||
const match = resolveMatch({
|
||||
channel: params.channel,
|
||||
entries,
|
||||
query,
|
||||
plugin,
|
||||
exactOnly: Boolean(reservedLiteral),
|
||||
});
|
||||
const match = resolveMatch({ channel: params.channel, entries, query, plugin });
|
||||
if (match.kind === "single") {
|
||||
const entry = match.entry;
|
||||
return {
|
||||
@@ -481,8 +445,7 @@ export async function resolveMessagingTarget(params: {
|
||||
target: {
|
||||
to: normalizeDirectoryEntryId(params.channel, entry, plugin),
|
||||
kind,
|
||||
display:
|
||||
entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id, params.channel, plugin),
|
||||
display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id),
|
||||
source: "directory",
|
||||
resolutionSource: "directory",
|
||||
},
|
||||
@@ -498,8 +461,7 @@ export async function resolveMessagingTarget(params: {
|
||||
target: {
|
||||
to: normalizeDirectoryEntryId(params.channel, best, plugin),
|
||||
kind,
|
||||
display:
|
||||
best.name ?? best.handle ?? stripTargetPrefixes(best.id, params.channel, plugin),
|
||||
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
|
||||
source: "directory",
|
||||
resolutionSource: "directory",
|
||||
},
|
||||
@@ -512,10 +474,6 @@ export async function resolveMessagingTarget(params: {
|
||||
candidates: match.entries,
|
||||
};
|
||||
}
|
||||
// Directory misses are the fail-closed boundary for reserved literals.
|
||||
if (reservedLiteral) {
|
||||
return { ok: false, error: reservedTargetLiteralError(providerLabel, reservedLiteral, hint) };
|
||||
}
|
||||
const resolvedFallbackTarget = asResolvedMessagingTarget(
|
||||
await maybeResolvePluginMessagingTarget({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -8,8 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel-constants.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import { validateTargetProviderPrefix } from "./channel-target-prefix.js";
|
||||
import { missingTargetError, reservedTargetLiteralError } from "./target-errors.js";
|
||||
import { resolveReservedTargetLiteral } from "./target-normalization.js";
|
||||
import { missingTargetError } from "./target-errors.js";
|
||||
|
||||
/**
|
||||
* Result of resolving a concrete outbound target for a channel send.
|
||||
@@ -80,21 +79,6 @@ export function resolveOutboundTargetWithPlugin(params: {
|
||||
if (targetPrefixError) {
|
||||
return { ok: false, error: targetPrefixError };
|
||||
}
|
||||
const hint = plugin.messaging?.targetResolver?.hint;
|
||||
// Heartbeats defer reserved literals to the async resolver so directory hits can win.
|
||||
if (params.target.mode !== "heartbeat") {
|
||||
const reservedLiteral = resolveReservedTargetLiteral({ raw: effectiveTo, plugin });
|
||||
if (reservedLiteral) {
|
||||
return {
|
||||
ok: false,
|
||||
error: reservedTargetLiteralError(
|
||||
plugin.meta.label ?? params.target.channel,
|
||||
reservedLiteral,
|
||||
hint,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTarget = plugin.outbound?.resolveTarget;
|
||||
if (resolveTarget) {
|
||||
@@ -110,6 +94,7 @@ export function resolveOutboundTargetWithPlugin(params: {
|
||||
if (effectiveTo) {
|
||||
return { ok: true, to: effectiveTo };
|
||||
}
|
||||
const hint = plugin.messaging?.targetResolver?.hint;
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError(plugin.meta.label ?? params.target.channel, hint),
|
||||
|
||||
@@ -100,73 +100,6 @@ export function runResolveOutboundTargetCoreTests(): void {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["current", "telegram:current", "tg:self"])(
|
||||
"rejects plugin-reserved literal target %s before direct outbound fallback",
|
||||
(to) => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
createTestChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }),
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "telegram",
|
||||
to,
|
||||
mode: "explicit",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.error.message).toContain("Reserved target");
|
||||
expect(res.error.message).toContain("Telegram");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("allows explicit handles that include the provider handle marker", () => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
createTestChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async () => ({ channel: "telegram", messageId: "telegram-msg" }),
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "telegram",
|
||||
to: "telegram:@current",
|
||||
mode: "explicit",
|
||||
});
|
||||
|
||||
expect(res).toEqual({ ok: true, to: "telegram:@current" });
|
||||
});
|
||||
|
||||
it("uses the plugin hint when a channel has outbound support but no target resolver", () => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
|
||||
@@ -1194,117 +1194,6 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
expect(resolved.reason).toBe("dm-blocked");
|
||||
});
|
||||
|
||||
it("resolves heartbeat reserved targets through directory before session routing", async () => {
|
||||
const listGroups = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ kind: "group", id: "-1002458651455", name: "current" }]);
|
||||
const listGroupsLive = vi.fn().mockResolvedValue([]);
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...createTestChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ to }) =>
|
||||
to
|
||||
? { ok: true as const, to: to.trim() }
|
||||
: { ok: false as const, error: new Error("target required") },
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ target, resolvedTarget }) => ({
|
||||
sessionKey: `main:telegram:group:${target}`,
|
||||
baseSessionKey: `main:telegram:group:${target}`,
|
||||
peer: { kind: resolvedTarget?.kind === "user" ? "direct" : "group", id: target },
|
||||
chatType: resolvedTarget?.kind === "user" ? "direct" : "group",
|
||||
from: `telegram:group:${target}`,
|
||||
to: target,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
directory: {
|
||||
listGroups,
|
||||
listGroupsLive,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
heartbeat: {
|
||||
target: "telegram",
|
||||
to: "current",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("telegram");
|
||||
expect(resolved.to).toBe("-1002458651455");
|
||||
expect(listGroups).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when a heartbeat reserved target misses the directory", async () => {
|
||||
const listGroups = vi.fn().mockResolvedValue([]);
|
||||
const listGroupsLive = vi.fn().mockResolvedValue([]);
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
{
|
||||
...createTestChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ to }) =>
|
||||
to
|
||||
? { ok: true as const, to: to.trim() }
|
||||
: { ok: false as const, error: new Error("target required") },
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
targetResolver: {
|
||||
reservedLiterals: ["current", "self", "this", "me"],
|
||||
hint: "<chatId>",
|
||||
},
|
||||
resolveOutboundSessionRoute: ({ target }) => ({
|
||||
sessionKey: `main:telegram:group:${target}`,
|
||||
baseSessionKey: `main:telegram:group:${target}`,
|
||||
peer: { kind: "group", id: target },
|
||||
chatType: "group",
|
||||
from: `telegram:group:${target}`,
|
||||
to: target,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
directory: {
|
||||
listGroups,
|
||||
listGroupsLive,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
heartbeat: {
|
||||
target: "telegram",
|
||||
to: "current",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("none");
|
||||
expect(resolved.reason).toBe("no-target");
|
||||
expect(listGroups).toHaveBeenCalled();
|
||||
expect(listGroupsLive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps heartbeat route canonicalization best-effort when target resolution fails", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTargetsTestRegistry([
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
resolveOutboundChannelPlugin,
|
||||
} from "./channel-resolution.js";
|
||||
import { resolveOutboundSessionRoute } from "./outbound-session.js";
|
||||
import { isReservedTargetLiteralError } from "./target-errors.js";
|
||||
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
|
||||
import {
|
||||
resolveOutboundTargetWithPlugin,
|
||||
@@ -362,13 +361,6 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
|
||||
})();
|
||||
if (targetResolution?.ok) {
|
||||
routeResolvedTarget = targetResolution.target;
|
||||
} else if (targetResolution && isReservedTargetLiteralError(targetResolution.error)) {
|
||||
return buildNoHeartbeatDeliveryTarget({
|
||||
reason: "no-target",
|
||||
accountId: delivery.accountId,
|
||||
lastChannel: delivery.lastChannel,
|
||||
lastAccountId: delivery.lastAccountId,
|
||||
});
|
||||
}
|
||||
if (routeResolvedTarget?.kind === "user" && heartbeat?.directPolicy === "block") {
|
||||
return buildNoHeartbeatDeliveryTarget({
|
||||
|
||||
@@ -573,31 +573,6 @@ describe("loadWebMedia", () => {
|
||||
expect(result.fileName).toBe("fake.png");
|
||||
});
|
||||
|
||||
it("strips internal media-store UUID suffix from outbound fileName", async () => {
|
||||
const stagedName = "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.png";
|
||||
const mediaDir = path.join(stateDir, "media", "outbound");
|
||||
const stagedFile = path.join(mediaDir, stagedName);
|
||||
await fs.mkdir(mediaDir, { recursive: true });
|
||||
await fs.writeFile(stagedFile, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const result = await loadWebMedia(stagedFile, {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: [mediaDir],
|
||||
});
|
||||
|
||||
expect(result.fileName).toBe("report.png");
|
||||
});
|
||||
|
||||
it("preserves non-media-store filenames that match the UUID suffix shape", async () => {
|
||||
const fileName = "report---a1b2c3d4-5678-90ab-cdef-1234567890ab.png";
|
||||
const filePath = path.join(fixtureRoot, fileName);
|
||||
await fs.writeFile(filePath, Buffer.from(TINY_PNG_BASE64, "base64"));
|
||||
|
||||
const result = await loadWebMedia(filePath, createLocalWebMediaOptions());
|
||||
|
||||
expect(result.fileName).toBe(fileName);
|
||||
});
|
||||
|
||||
it("uses only the leaf filename from Windows-style sandbox-validated media paths", async () => {
|
||||
const result = await loadWebMedia(String.raw`C:\workspace\captures\tiny.png`, {
|
||||
maxBytes: 1024 * 1024,
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
readImageMetadataFromHeader,
|
||||
readImageProbeFromHeader,
|
||||
} from "./media-services.js";
|
||||
import { extractOriginalFilename, getMediaDir } from "./store.js";
|
||||
|
||||
export { getDefaultLocalRoots, LocalMediaAccessError };
|
||||
export type { LocalMediaAccessErrorCode };
|
||||
@@ -285,13 +284,6 @@ function isPathInsideRoot(filePath: string | undefined, root: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLocalMediaFileName(filePath: string): string | undefined {
|
||||
const fileName = basenameFromAnyPath(filePath) || undefined;
|
||||
return fileName && isPathInsideRoot(filePath, getMediaDir())
|
||||
? extractOriginalFilename(fileName)
|
||||
: fileName;
|
||||
}
|
||||
|
||||
function hasHtmlDocumentShape(text: string): boolean {
|
||||
const sample = text.trimStart().slice(0, 8192);
|
||||
return /^(?:<!doctype\s+html\b|<html\b)/iu.test(sample) || /<\/(?:html|body)>/iu.test(sample);
|
||||
@@ -1082,7 +1074,7 @@ async function loadWebMediaInternal(
|
||||
trustedGeneratedHtmlPath,
|
||||
});
|
||||
}
|
||||
let fileName = resolveLocalMediaFileName(mediaUrl);
|
||||
let fileName = basenameFromAnyPath(mediaUrl) || undefined;
|
||||
if (fileName && !extnameFromAnyPath(fileName) && mime) {
|
||||
const ext = extensionForMime(mime);
|
||||
if (ext) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
readPersistedInstalledPluginIndex,
|
||||
writePersistedInstalledPluginIndex,
|
||||
} from "./installed-plugin-index-store.js";
|
||||
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
|
||||
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import {
|
||||
loadPluginManifestRegistryForInstalledIndex,
|
||||
resolveInstalledManifestRegistryIndexFingerprint,
|
||||
@@ -151,24 +151,6 @@ function createIndexWithPackageJson(rootDir: string): InstalledPluginIndex {
|
||||
};
|
||||
}
|
||||
|
||||
function createIndexWithUnhashedPackageJson(rootDir: string): InstalledPluginIndex {
|
||||
const index = createIndexWithFileSignatures(rootDir);
|
||||
const packageJsonPath = writePackageManifest(rootDir, "Installed");
|
||||
const record = index.plugins[0];
|
||||
if (!record) {
|
||||
throw new Error("expected index record");
|
||||
}
|
||||
record.packageJson = {
|
||||
path: "package.json",
|
||||
hash: "",
|
||||
fileSignature: fileSignature(packageJsonPath),
|
||||
};
|
||||
return {
|
||||
...index,
|
||||
plugins: [record],
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadPluginManifestRegistryForInstalledIndex", () => {
|
||||
it("reuses frozen installed-index fingerprints when file signatures are persisted", () => {
|
||||
const rootDir = makeTempDir();
|
||||
@@ -198,97 +180,6 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => {
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("reuses package realpaths across mutable installed-index fingerprint builds", () => {
|
||||
const rootDir = makeTempDir();
|
||||
writePlugin(rootDir, "installed", "installed-");
|
||||
const index = createIndexWithUnhashedPackageJson(rootDir);
|
||||
const packageJsonPath = path.join(fs.realpathSync(rootDir), "package.json");
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
let rootPathCalls: unknown[][];
|
||||
let packageJsonPathCalls: unknown[][];
|
||||
try {
|
||||
resolveInstalledManifestRegistryIndexFingerprint(index);
|
||||
resolveInstalledManifestRegistryIndexFingerprint(index);
|
||||
rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === rootDir);
|
||||
packageJsonPathCalls = realpathSpy.mock.calls.filter(
|
||||
([filePath]) => filePath === packageJsonPath,
|
||||
);
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(rootPathCalls).toHaveLength(1);
|
||||
expect(packageJsonPathCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears package realpath memoization with plugin metadata lifecycle caches", () => {
|
||||
const rootDir = makeTempDir();
|
||||
writePlugin(rootDir, "installed", "installed-");
|
||||
const index = createIndexWithUnhashedPackageJson(rootDir);
|
||||
const packageJsonPath = path.join(fs.realpathSync(rootDir), "package.json");
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
let rootPathCalls: unknown[][];
|
||||
let packageJsonPathCalls: unknown[][];
|
||||
try {
|
||||
resolveInstalledManifestRegistryIndexFingerprint(index);
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
resolveInstalledManifestRegistryIndexFingerprint(index);
|
||||
rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === rootDir);
|
||||
packageJsonPathCalls = realpathSpy.mock.calls.filter(
|
||||
([filePath]) => filePath === packageJsonPath,
|
||||
);
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(rootPathCalls).toHaveLength(2);
|
||||
expect(packageJsonPathCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("bounds package realpath memoization across many fingerprint roots", () => {
|
||||
const firstRootDir = makeTempDir();
|
||||
writePlugin(firstRootDir, "installed", "installed-");
|
||||
const firstIndex = createIndexWithUnhashedPackageJson(firstRootDir);
|
||||
resolveInstalledManifestRegistryIndexFingerprint(firstIndex);
|
||||
|
||||
const records: InstalledPluginIndexRecord[] = [];
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
const rootDir = makeTempDir();
|
||||
const pluginId = `installed-${index}`;
|
||||
writePlugin(rootDir, pluginId, `${pluginId}-`);
|
||||
const record = createIndexWithUnhashedPackageJson(rootDir).plugins[0];
|
||||
if (!record) {
|
||||
throw new Error("expected index record");
|
||||
}
|
||||
records.push({
|
||||
...record,
|
||||
pluginId,
|
||||
manifestHash: `manifest-hash-${index}`,
|
||||
});
|
||||
}
|
||||
resolveInstalledManifestRegistryIndexFingerprint({
|
||||
...firstIndex,
|
||||
plugins: records,
|
||||
});
|
||||
|
||||
const packageJsonPath = path.join(fs.realpathSync(firstRootDir), "package.json");
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
let rootPathCalls: unknown[][];
|
||||
let packageJsonPathCalls: unknown[][];
|
||||
try {
|
||||
resolveInstalledManifestRegistryIndexFingerprint(firstIndex);
|
||||
rootPathCalls = realpathSpy.mock.calls.filter(([filePath]) => filePath === firstRootDir);
|
||||
packageJsonPathCalls = realpathSpy.mock.calls.filter(
|
||||
([filePath]) => filePath === packageJsonPath,
|
||||
);
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(rootPathCalls).toHaveLength(1);
|
||||
expect(packageJsonPathCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not cache shallow-frozen installed-index fingerprints with mutable nested records", () => {
|
||||
const rootDir = makeTempDir();
|
||||
writePlugin(rootDir, "installed", "installed-");
|
||||
|
||||
@@ -32,12 +32,8 @@ import {
|
||||
const installedManifestRegistryIndexFingerprintCache = new WeakMap<InstalledPluginIndex, string>();
|
||||
const installedPackageJsonPathCache = new Map<string, string | null>();
|
||||
const installedPackageMetadataCache = new Map<string, InstalledPackageMetadata>();
|
||||
// Installed plugin metadata is process-stable between explicit lifecycle clears.
|
||||
// Share realpaths across fingerprint builds to avoid repeated package boundary IO.
|
||||
const installedManifestRegistryRealpathCache = new Map<string, string>();
|
||||
const MAX_INSTALLED_PACKAGE_JSON_PATH_CACHE_ENTRIES = 256;
|
||||
const MAX_INSTALLED_PACKAGE_METADATA_CACHE_ENTRIES = 256;
|
||||
const MAX_INSTALLED_MANIFEST_REGISTRY_REALPATH_CACHE_ENTRIES = 512;
|
||||
|
||||
type InstalledPackageMetadata = {
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
@@ -48,7 +44,6 @@ type InstalledPackageMetadata = {
|
||||
export function clearInstalledManifestRegistryProcessCaches(): void {
|
||||
installedPackageJsonPathCache.clear();
|
||||
installedPackageMetadataCache.clear();
|
||||
installedManifestRegistryRealpathCache.clear();
|
||||
}
|
||||
|
||||
registerPluginMetadataProcessMemoLifecycleClear(clearInstalledManifestRegistryProcessCaches);
|
||||
@@ -174,19 +169,6 @@ function rememberInstalledPackageJsonPath(
|
||||
return packageJsonPath;
|
||||
}
|
||||
|
||||
function trimInstalledManifestRegistryRealpathCache(): void {
|
||||
while (
|
||||
installedManifestRegistryRealpathCache.size >
|
||||
MAX_INSTALLED_MANIFEST_REGISTRY_REALPATH_CACHE_ENTRIES
|
||||
) {
|
||||
const oldest = installedManifestRegistryRealpathCache.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
installedManifestRegistryRealpathCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstalledPackageJsonPathCacheKey(
|
||||
record: InstalledPluginIndexRecord,
|
||||
): string | undefined {
|
||||
@@ -214,6 +196,7 @@ function buildInstalledPackageMetadataCacheKey(params: {
|
||||
}
|
||||
|
||||
function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
|
||||
const realpathCache = new Map<string, string>();
|
||||
return {
|
||||
version: index.version,
|
||||
hostContractVersion: index.hostContractVersion,
|
||||
@@ -223,11 +206,7 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
|
||||
installRecords: index.installRecords,
|
||||
diagnostics: index.diagnostics,
|
||||
plugins: index.plugins.map((record) => {
|
||||
const packageJsonPath = resolvePackageJsonPath(
|
||||
record,
|
||||
installedManifestRegistryRealpathCache,
|
||||
);
|
||||
trimInstalledManifestRegistryRealpathCache();
|
||||
const packageJsonPath = resolvePackageJsonPath(record, realpathCache);
|
||||
const packageJsonFile = record.packageJson?.fileSignature
|
||||
? packageJsonPath
|
||||
? formatFileSignature(packageJsonPath, record.packageJson.fileSignature)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -593,17 +593,11 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
});
|
||||
const selectedProvider = entry?.providerOverride ?? resolved.provider ?? DEFAULT_PROVIDER;
|
||||
const selectedModel = entry?.modelOverride ?? resolved.model ?? DEFAULT_MODEL;
|
||||
const parseSelectedProvider = Boolean(
|
||||
entry?.modelOverride?.trim() && !entry?.providerOverride?.trim(),
|
||||
);
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
sessionEntry: entry,
|
||||
parseSelectedProvider,
|
||||
});
|
||||
const selectedLookupProvider = modelRefs.selected.provider || selectedProvider;
|
||||
const selectedLookupModel = modelRefs.selected.model || selectedModel;
|
||||
const initialFallbackState = resolveActiveFallbackState({
|
||||
selectedModelRef: modelRefs.selected.label || "unknown",
|
||||
activeModelRef: modelRefs.active.label || "unknown",
|
||||
@@ -724,8 +718,8 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const runtimeDiffersFromSelected = activeModelLabel !== (modelRefs.selected.label || "unknown");
|
||||
const selectedContextTokens = resolveContextTokensForModel({
|
||||
cfg: contextConfig,
|
||||
provider: selectedLookupProvider,
|
||||
model: selectedLookupModel,
|
||||
provider: selectedProvider,
|
||||
model: selectedModel,
|
||||
allowAsyncLoad: false,
|
||||
});
|
||||
const explicitRuntimeContextTokens =
|
||||
@@ -746,8 +740,8 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const channelModelNote = resolveChannelModelNote({
|
||||
config: args.config,
|
||||
entry,
|
||||
selectedProvider: selectedLookupProvider,
|
||||
selectedModel: selectedLookupModel,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
parentSessionKey: args.parentSessionKey,
|
||||
});
|
||||
const persistedContextTokens =
|
||||
@@ -1013,7 +1007,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
{ config: args.config },
|
||||
);
|
||||
const selectedAuthMode =
|
||||
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedLookupProvider, args.config);
|
||||
normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config);
|
||||
const rawSelectedAuthLabelValue =
|
||||
selectedAuthMode && selectedAuthMode !== "unknown"
|
||||
? (args.modelAuth ?? selectedAuthMode)
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
formatTaskStatusDetail,
|
||||
formatTaskStatusTitle,
|
||||
} from "../tasks/task-status.js";
|
||||
import { resolveActiveFallbackState } from "./fallback-notice-state.js";
|
||||
import { formatCompactPluginHealthLine } from "./status-plugin-health.js";
|
||||
import type { BuildStatusTextParams } from "./status-text.types.js";
|
||||
|
||||
@@ -353,35 +352,27 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
params.workspaceDir ??
|
||||
sessionEntry?.spawnedWorkspaceDir ??
|
||||
resolveAgentWorkspaceDir(cfg, statusAgentId);
|
||||
const selectedProvider = sessionEntry?.providerOverride?.trim() ?? provider;
|
||||
const selectedModel = sessionEntry?.modelOverride?.trim() ?? model;
|
||||
const parseSelectedProvider = Boolean(
|
||||
sessionEntry?.modelOverride?.trim() && !sessionEntry?.providerOverride?.trim(),
|
||||
);
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
selectedProvider: provider,
|
||||
selectedModel: model,
|
||||
sessionEntry,
|
||||
parseSelectedProvider,
|
||||
});
|
||||
const selectedLookupProvider = modelRefs.selected.provider || selectedProvider || provider;
|
||||
const selectedLookupModel = modelRefs.selected.model || selectedModel || model;
|
||||
const effectiveHarness =
|
||||
params.resolvedHarness ??
|
||||
(await resolveStatusHarnessId({
|
||||
cfg,
|
||||
provider: selectedLookupProvider,
|
||||
model: selectedLookupModel,
|
||||
provider,
|
||||
model,
|
||||
agentId: statusAgentId,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
}));
|
||||
const selectedStatusProvider = resolveStatusRuntimeProvider({
|
||||
provider: selectedLookupProvider,
|
||||
provider,
|
||||
effectiveHarness,
|
||||
});
|
||||
const selectedAuthProviders = listOpenAIAuthProfileProvidersForAgentRuntime({
|
||||
provider: selectedLookupProvider,
|
||||
provider,
|
||||
harnessRuntime: effectiveHarness,
|
||||
config: cfg,
|
||||
});
|
||||
@@ -424,12 +415,6 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
modelRefs.active.label,
|
||||
{ config: cfg },
|
||||
);
|
||||
const fallbackState = resolveActiveFallbackState({
|
||||
selectedModelRef: modelRefs.selected.label || "unknown",
|
||||
activeModelRef: modelRefs.active.label || "unknown",
|
||||
config: cfg,
|
||||
state: sessionEntry,
|
||||
});
|
||||
if (
|
||||
shouldPreferActiveRuntimeAliasAuthLabel({
|
||||
runtimeAliasModelEquivalent,
|
||||
@@ -441,20 +426,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
// labels differ; prefer the active auth label so status matches execution.
|
||||
selectedModelAuth = activeModelAuth;
|
||||
}
|
||||
const activeRuntimeIsAuthoritative =
|
||||
!modelRefs.activeDiffers ||
|
||||
fallbackState.active ||
|
||||
hasSessionAutoModelFallbackProvenance(sessionEntry) ||
|
||||
runtimeAliasModelEquivalent;
|
||||
const usageAuthLabel = activeRuntimeIsAuthoritative ? activeModelAuth : selectedModelAuth;
|
||||
const usageStatusProvider = activeRuntimeIsAuthoritative
|
||||
? activeStatusProvider
|
||||
: selectedStatusProvider;
|
||||
const usageProvider = activeRuntimeIsAuthoritative ? activeProvider : selectedLookupProvider;
|
||||
const usageAuthLabel = modelRefs.activeDiffers ? activeModelAuth : selectedModelAuth;
|
||||
const selectedUsageCredentialType = resolveUsageCredentialType(usageAuthLabel);
|
||||
const useCodexSyntheticUsage =
|
||||
shouldUseCodexSyntheticUsage({
|
||||
provider: usageStatusProvider,
|
||||
provider: activeStatusProvider,
|
||||
effectiveHarness,
|
||||
}) &&
|
||||
(selectedUsageCredentialType === "oauth" || selectedUsageCredentialType === "token");
|
||||
@@ -467,8 +443,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
: undefined;
|
||||
const usageCredentialType = useCodexSyntheticUsage ? "token" : selectedUsageCredentialType;
|
||||
const currentUsageProvider =
|
||||
resolveUsageProviderId(usageStatusProvider, { credentialType: usageCredentialType }) ??
|
||||
resolveUsageProviderId(usageProvider, { credentialType: usageCredentialType });
|
||||
resolveUsageProviderId(activeStatusProvider, { credentialType: usageCredentialType }) ??
|
||||
resolveUsageProviderId(activeProvider, { credentialType: usageCredentialType });
|
||||
let usageLine: string | null = null;
|
||||
if (
|
||||
currentUsageProvider &&
|
||||
@@ -614,21 +590,24 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
const selectedContextTokens = resolveStatusRuntimeContextTokens({
|
||||
cfg,
|
||||
provider: selectedStatusProvider,
|
||||
model: modelRefs.selected.model || selectedLookupModel,
|
||||
model,
|
||||
});
|
||||
const runtimeSnapshotHasFallbackProvenance =
|
||||
!modelRefs.activeDiffers ||
|
||||
hasSessionAutoModelFallbackProvenance(sessionEntry) ||
|
||||
areRuntimeModelRefsEquivalent(modelRefs.active.label, modelRefs.selected.label, {
|
||||
config: cfg,
|
||||
});
|
||||
const statusAgentContextTokens =
|
||||
typeof contextTokens === "number" &&
|
||||
contextTokens > 0 &&
|
||||
(activeRuntimeIsAuthoritative ||
|
||||
(runtimeSnapshotHasFallbackProvenance ||
|
||||
contextTokens === configuredContextTokens ||
|
||||
contextTokens === selectedContextTokens)
|
||||
? contextTokens
|
||||
: undefined;
|
||||
const statusRuntimeContextTokens = activeRuntimeIsAuthoritative
|
||||
? (runtimeContextTokens ??
|
||||
(fallbackState.active && typeof contextTokens === "number" && contextTokens > 0
|
||||
? contextTokens
|
||||
: undefined))
|
||||
const statusRuntimeContextTokens = runtimeSnapshotHasFallbackProvenance
|
||||
? runtimeContextTokens
|
||||
: undefined;
|
||||
return buildStatusMessage({
|
||||
config: cfg,
|
||||
|
||||
@@ -1467,39 +1467,4 @@ describe("exportTrajectoryBundle", () => {
|
||||
expect(tools).toContain("$WORKSPACE_DIR/docs");
|
||||
expect(`${prompts}\n${artifacts}\n${systemPrompt}\n${tools}`).not.toContain(tmpDir);
|
||||
});
|
||||
|
||||
it("exports the transcript for a legacy v1 session without entry timestamps", async () => {
|
||||
const tmpDir = makeTempDir();
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const outputDir = path.join(tmpDir, "bundle");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 1,
|
||||
id: "session-1",
|
||||
cwd: tmpDir,
|
||||
};
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
message: userMessage("hello"),
|
||||
};
|
||||
const assistantEntry = {
|
||||
type: "message",
|
||||
message: assistantMessage([{ type: "text", text: "done" }]),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
`${[header, userEntry, assistantEntry].map((entry) => JSON.stringify(entry)).join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const bundle = await exportTrajectoryBundle({
|
||||
outputDir,
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
workspaceDir: tmpDir,
|
||||
});
|
||||
|
||||
expect(bundle.manifest.transcriptEventCount).toBe(2);
|
||||
expect(eventTypes(bundle.events)).toEqual(["user.message", "assistant.message"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,7 +185,9 @@ async function readSessionBranch(filePath: string): Promise<{
|
||||
(entry): entry is SessionEntry =>
|
||||
entry.type !== "session" &&
|
||||
isCanonicalSessionTranscriptEntry(entry) &&
|
||||
typeof (entry as { id?: unknown }).id === "string",
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
(typeof (entry as { timestamp?: unknown }).timestamp === "string" ||
|
||||
typeof (entry as { timestamp?: unknown }).timestamp === "number"),
|
||||
);
|
||||
const tree = scanSessionTranscriptTree(fileEntries);
|
||||
if (!tree.hasLeafUpdate) {
|
||||
|
||||
@@ -46,13 +46,6 @@ describe("estimateStringChars", () => {
|
||||
expect(estimateStringChars("안녕하세요")).toBe(20);
|
||||
});
|
||||
|
||||
it("handles East Asian fullwidth letters, numbers, and punctuation", () => {
|
||||
expect(estimateStringChars("ABC123")).toBe(6 * CHARS_PER_TOKEN_ESTIMATE);
|
||||
expect(estimateStringChars("hello,world")).toBe(
|
||||
"helloworld".length + CHARS_PER_TOKEN_ESTIMATE,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles CJK punctuation and symbols in the extended range", () => {
|
||||
// "⺀" (U+2E80) is in CJK Radicals Supplement range
|
||||
expect(estimateStringChars("⺀")).toBe(CHARS_PER_TOKEN_ESTIMATE);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user