mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 23:42:53 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { HandleCommandsParams } from "./commands-types.js";
|
|
|
|
const hoisted = await vi.hoisted(async () => {
|
|
const { createExportCommandSessionMocks } = await import("./commands-export-test-mocks.js");
|
|
return {
|
|
...createExportCommandSessionMocks(vi),
|
|
resolveCommandsSystemPromptBundleMock: vi.fn(async () => ({
|
|
systemPrompt: "system prompt",
|
|
tools: [],
|
|
skillsPrompt: "",
|
|
bootstrapFiles: [],
|
|
injectedFiles: [],
|
|
sandboxRuntime: { sandboxed: false, mode: "off" },
|
|
})),
|
|
writeFileMock: vi.fn(
|
|
async (_filePath: string, dataValue: string, _encoding?: BufferEncoding) => undefined,
|
|
),
|
|
mkdirMock: vi.fn(async (_filePath: string, _options?: { recursive?: boolean }) => undefined),
|
|
accessMock: vi.fn(async (_filePath: string) => undefined),
|
|
pathExistsMock: vi.fn(async (_filePath: string) => true),
|
|
migrateSessionEntriesMock: vi.fn((_entries: unknown[]) => undefined),
|
|
exportHtmlTemplateContents: new Map<string, string>(),
|
|
sessionTranscriptContent: "",
|
|
};
|
|
});
|
|
|
|
vi.mock("../../config/sessions/paths.js", () => ({
|
|
resolveDefaultSessionStorePath: hoisted.resolveDefaultSessionStorePathMock,
|
|
resolveSessionFilePath: hoisted.resolveSessionFilePathMock,
|
|
resolveSessionFilePathOptions: hoisted.resolveSessionFilePathOptionsMock,
|
|
}));
|
|
|
|
vi.mock("../../config/sessions/store.js", () => ({
|
|
loadSessionStore: hoisted.loadSessionStoreMock,
|
|
}));
|
|
|
|
vi.mock("./commands-system-prompt.js", () => ({
|
|
resolveCommandsSystemPromptBundle: hoisted.resolveCommandsSystemPromptBundleMock,
|
|
}));
|
|
|
|
vi.mock("../../infra/fs-safe.js", () => ({
|
|
pathExists: hoisted.pathExistsMock,
|
|
}));
|
|
|
|
vi.mock("../../agents/sessions/session-manager.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../agents/sessions/session-manager.js")>();
|
|
return {
|
|
...actual,
|
|
migrateSessionEntries: hoisted.migrateSessionEntriesMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("node:fs", async () => {
|
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
const mockedFs = {
|
|
...actual,
|
|
readFileSync: vi.fn((filePath: string) => {
|
|
for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) {
|
|
if (filePath.endsWith(suffix)) {
|
|
return contents;
|
|
}
|
|
}
|
|
if (filePath.includes("/export-html/")) {
|
|
return actual.readFileSync(filePath, "utf8");
|
|
}
|
|
return actual.readFileSync(filePath, "utf8");
|
|
}),
|
|
};
|
|
return {
|
|
...mockedFs,
|
|
default: mockedFs,
|
|
};
|
|
});
|
|
|
|
vi.mock("node:fs/promises", async () => {
|
|
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
|
const mockedFsPromises = {
|
|
...actual,
|
|
access: hoisted.accessMock,
|
|
mkdir: hoisted.mkdirMock,
|
|
writeFile: hoisted.writeFileMock,
|
|
readFile: vi.fn(async (filePath: string, encoding?: BufferEncoding) => {
|
|
if (filePath === "/tmp/target-store/session.jsonl") {
|
|
return hoisted.sessionTranscriptContent;
|
|
}
|
|
for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) {
|
|
if (filePath.endsWith(suffix)) {
|
|
return contents;
|
|
}
|
|
}
|
|
return actual.readFile(filePath, encoding);
|
|
}),
|
|
};
|
|
return {
|
|
...mockedFsPromises,
|
|
default: mockedFsPromises,
|
|
};
|
|
});
|
|
|
|
import { buildExportSessionReply } from "./commands-export-session.js";
|
|
|
|
function makeParams(): HandleCommandsParams {
|
|
return {
|
|
cfg: {},
|
|
ctx: {
|
|
SessionKey: "agent:main:slash-session",
|
|
},
|
|
command: {
|
|
commandBodyNormalized: "/export-session",
|
|
isAuthorizedSender: true,
|
|
senderIsOwner: true,
|
|
senderId: "sender-1",
|
|
channel: "quietchat",
|
|
surface: "quietchat",
|
|
ownerList: [],
|
|
rawBodyNormalized: "/export-session",
|
|
},
|
|
sessionEntry: {
|
|
sessionId: "session-1",
|
|
updatedAt: 1,
|
|
},
|
|
sessionKey: "agent:target:session",
|
|
workspaceDir: "/tmp/workspace",
|
|
directives: {},
|
|
elevated: { enabled: true, allowed: true, failures: [] },
|
|
defaultGroupActivation: () => "mention",
|
|
resolvedVerboseLevel: "off",
|
|
resolvedReasoningLevel: "off",
|
|
resolveDefaultThinkingLevel: async () => undefined,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
contextTokens: 0,
|
|
isGroup: false,
|
|
} as unknown as HandleCommandsParams;
|
|
}
|
|
|
|
function writeFileArg(callIndex: number, argIndex: number): unknown {
|
|
const call = hoisted.writeFileMock.mock.calls.at(callIndex);
|
|
if (!call) {
|
|
throw new Error(`Expected writeFile call ${callIndex}`);
|
|
}
|
|
if (!(argIndex in call)) {
|
|
throw new Error(`Expected writeFile call ${callIndex} argument ${argIndex}`);
|
|
}
|
|
return call[argIndex];
|
|
}
|
|
|
|
function writeFilePath(callIndex: number): string {
|
|
const value = writeFileArg(callIndex, 0);
|
|
if (typeof value !== "string") {
|
|
throw new Error(`Expected writeFile call ${callIndex} path`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function writtenHtml(): string {
|
|
const value = writeFileArg(0, 1);
|
|
if (typeof value !== "string") {
|
|
throw new Error("Expected exported HTML");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
describe("buildExportSessionReply", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
hoisted.resolveDefaultSessionStorePathMock.mockReturnValue("/tmp/target-store/sessions.json");
|
|
hoisted.resolveSessionFilePathMock.mockReturnValue("/tmp/target-store/session.jsonl");
|
|
hoisted.resolveSessionFilePathOptionsMock.mockImplementation(
|
|
(params: { agentId: string; storePath: string }) => params,
|
|
);
|
|
hoisted.loadSessionStoreMock.mockReturnValue({
|
|
"agent:target:session": {
|
|
sessionId: "session-1",
|
|
updatedAt: 1,
|
|
},
|
|
});
|
|
hoisted.resolveCommandsSystemPromptBundleMock.mockResolvedValue({
|
|
systemPrompt: "system prompt",
|
|
tools: [],
|
|
skillsPrompt: "",
|
|
bootstrapFiles: [],
|
|
injectedFiles: [],
|
|
sandboxRuntime: { sandboxed: false, mode: "off" },
|
|
});
|
|
hoisted.accessMock.mockResolvedValue(undefined);
|
|
hoisted.pathExistsMock.mockResolvedValue(true);
|
|
hoisted.exportHtmlTemplateContents.clear();
|
|
hoisted.sessionTranscriptContent = "";
|
|
});
|
|
|
|
it("resolves store and transcript paths from the target session agent", async () => {
|
|
await buildExportSessionReply(makeParams());
|
|
|
|
expect(hoisted.resolveDefaultSessionStorePathMock).toHaveBeenCalledWith("target");
|
|
expect(hoisted.resolveSessionFilePathOptionsMock).toHaveBeenCalledWith({
|
|
agentId: "target",
|
|
storePath: "/tmp/target-store/sessions.json",
|
|
});
|
|
});
|
|
|
|
it("prefers the active command storePath over the default target-agent store", async () => {
|
|
hoisted.loadSessionStoreMock.mockReturnValue({
|
|
"agent:target:session": {
|
|
sessionId: "session-1",
|
|
updatedAt: 1,
|
|
},
|
|
});
|
|
|
|
await buildExportSessionReply({
|
|
...makeParams(),
|
|
storePath: "/tmp/custom-store/sessions.json",
|
|
});
|
|
|
|
expect(hoisted.resolveDefaultSessionStorePathMock).not.toHaveBeenCalled();
|
|
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledWith("/tmp/custom-store/sessions.json", {
|
|
skipCache: true,
|
|
});
|
|
expect(hoisted.resolveSessionFilePathOptionsMock).toHaveBeenCalledWith({
|
|
agentId: "target",
|
|
storePath: "/tmp/custom-store/sessions.json",
|
|
});
|
|
});
|
|
|
|
it("uses the target store entry even when the wrapper sessionEntry is missing", async () => {
|
|
hoisted.loadSessionStoreMock.mockReturnValue({
|
|
"agent:target:session": {
|
|
sessionId: "session-from-store",
|
|
updatedAt: 2,
|
|
},
|
|
});
|
|
|
|
const reply = await buildExportSessionReply({
|
|
...makeParams(),
|
|
sessionEntry: undefined,
|
|
});
|
|
|
|
expect(reply.text).toContain("✅ Session exported!");
|
|
const [[systemPromptBundleParams]] = hoisted.resolveCommandsSystemPromptBundleMock.mock
|
|
.calls as unknown as Array<[{ sessionEntry?: { sessionId?: string; updatedAt?: number } }]>;
|
|
expect(systemPromptBundleParams?.sessionEntry?.sessionId).toBe("session-from-store");
|
|
expect(systemPromptBundleParams?.sessionEntry?.updatedAt).toBe(2);
|
|
});
|
|
|
|
it("injects scripts and session data through the real export template", async () => {
|
|
await buildExportSessionReply(makeParams());
|
|
|
|
const html = writtenHtml();
|
|
expect(html).not.toContain("{{CSS}}");
|
|
expect(html).not.toContain("{{JS}}");
|
|
expect(html).not.toContain("{{SESSION_DATA}}");
|
|
expect(html).not.toContain("{{MARKED_JS}}");
|
|
expect(html).not.toContain("{{HIGHLIGHT_JS}}");
|
|
expect(html).not.toContain("data-openclaw-export-placeholder");
|
|
expect(html).toContain(
|
|
Buffer.from(
|
|
JSON.stringify({
|
|
header: null,
|
|
entries: [],
|
|
leafId: null,
|
|
systemPrompt: "system prompt",
|
|
tools: [],
|
|
}),
|
|
).toString("base64"),
|
|
);
|
|
expect(html).toContain('const base64 = document.getElementById("session-data").textContent;');
|
|
});
|
|
|
|
it("suffixes colliding default export filenames instead of overwriting", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z"));
|
|
const collision = Object.assign(new Error("exists"), { code: "EEXIST" });
|
|
hoisted.writeFileMock.mockRejectedValueOnce(collision).mockResolvedValueOnce(undefined);
|
|
|
|
const reply = await buildExportSessionReply(makeParams());
|
|
|
|
const expectedBase = path.join(
|
|
"/tmp/workspace",
|
|
"openclaw-session-session--2026-05-05T10-11-12.html",
|
|
);
|
|
const expectedSuffix = path.join(
|
|
"/tmp/workspace",
|
|
"openclaw-session-session--2026-05-05T10-11-12-2.html",
|
|
);
|
|
expect(writeFilePath(0)).toBe(expectedBase);
|
|
expect(writeFileArg(0, 2)).toEqual({
|
|
encoding: "utf-8",
|
|
flag: "wx",
|
|
});
|
|
expect(writeFilePath(1)).toBe(expectedSuffix);
|
|
expect(reply.text).toContain("📄 File: openclaw-session-session--2026-05-05T10-11-12-2.html");
|
|
});
|
|
|
|
it("preserves replacement text with dollar sequences", async () => {
|
|
hoisted.exportHtmlTemplateContents.set(
|
|
"template.html",
|
|
[
|
|
'<style data-openclaw-export-placeholder="CSS"></style>',
|
|
'<script id="session-data" type="application/json" data-openclaw-export-placeholder="SESSION_DATA"></script>',
|
|
'<script data-openclaw-export-placeholder="MARKED_JS"></script>',
|
|
'<script data-openclaw-export-placeholder="HIGHLIGHT_JS"></script>',
|
|
'<script data-openclaw-export-placeholder="JS"></script>',
|
|
].join(""),
|
|
);
|
|
hoisted.exportHtmlTemplateContents.set("template.css", "/* {{THEME_VARS}} */$&$1");
|
|
hoisted.exportHtmlTemplateContents.set("template.js", "const marker = '$&$1';");
|
|
hoisted.exportHtmlTemplateContents.set("vendor/marked.min.js", "const markedMarker = '$&$1';");
|
|
hoisted.exportHtmlTemplateContents.set(
|
|
"vendor/highlight.min.js",
|
|
"const highlightMarker = '$&$1';",
|
|
);
|
|
|
|
await buildExportSessionReply(makeParams());
|
|
|
|
const html = writtenHtml();
|
|
expect(html).toContain("$&$1");
|
|
expect(html).toContain("const marker = '$&$1';");
|
|
expect(html).toContain("const markedMarker = '$&$1';");
|
|
expect(html).toContain("const highlightMarker = '$&$1';");
|
|
});
|
|
|
|
it("reports malformed transcript rows without leaking parser details", async () => {
|
|
hoisted.sessionTranscriptContent = [
|
|
JSON.stringify({ type: "session", version: 3, id: "session-1" }),
|
|
'{"type":"message",',
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "entry-1",
|
|
timestamp: "2026-05-16T00:00:00.000Z",
|
|
message: { role: "user", content: "valid user" },
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "entry-2",
|
|
timestamp: "2026-05-16T00:00:01.000Z",
|
|
message: { content: "missing role" },
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
id: "entry-3",
|
|
timestamp: "2026-05-16T00:00:02.000Z",
|
|
message: { role: "assistant", content: "valid assistant" },
|
|
}),
|
|
].join("\n");
|
|
|
|
const reply = await buildExportSessionReply(makeParams());
|
|
|
|
expect(reply.text).toContain("📊 Entries: 2");
|
|
expect(reply.text).toContain(
|
|
"⚠️ Skipped 1 malformed transcript row that was not valid JSON. rows 2",
|
|
);
|
|
expect(reply.text).toContain(
|
|
"⚠️ Skipped 1 malformed transcript row that was not a session entry. rows 4",
|
|
);
|
|
expect(reply.text).not.toMatch(/Unexpected|SyntaxError|position/i);
|
|
});
|
|
});
|