mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 15:22:56 +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
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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 type { OpenClawConfig } from "../../config/config.js";
|
|
import type { SessionEntry } from "../../config/sessions.js";
|
|
import type { HookRunner } from "../../plugins/hooks.js";
|
|
import { initSessionState } from "./session.js";
|
|
|
|
const hookRunnerMocks = vi.hoisted(() => ({
|
|
hasHooks: vi.fn<HookRunner["hasHooks"]>(),
|
|
runSessionStart: vi.fn<HookRunner["runSessionStart"]>(),
|
|
runSessionEnd: vi.fn<HookRunner["runSessionEnd"]>(),
|
|
}));
|
|
const sessionCleanupMocks = vi.hoisted(() => ({
|
|
closeTrackedBrowserTabsForSessions: vi.fn(async () => 0),
|
|
resetRegisteredAgentHarnessSessions: vi.fn(async () => undefined),
|
|
retireSessionMcpRuntime: vi.fn(async () => false),
|
|
}));
|
|
|
|
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
|
getGlobalHookRunner: () =>
|
|
({
|
|
hasHooks: hookRunnerMocks.hasHooks,
|
|
runSessionStart: hookRunnerMocks.runSessionStart,
|
|
runSessionEnd: hookRunnerMocks.runSessionEnd,
|
|
}) as unknown as HookRunner,
|
|
}));
|
|
|
|
vi.mock("../../agents/harness/registry.js", () => ({
|
|
resetRegisteredAgentHarnessSessions: sessionCleanupMocks.resetRegisteredAgentHarnessSessions,
|
|
}));
|
|
|
|
vi.mock("../../agents/agent-bundle-mcp-tools.js", () => ({
|
|
retireSessionMcpRuntime: sessionCleanupMocks.retireSessionMcpRuntime,
|
|
}));
|
|
|
|
vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
|
|
closeTrackedBrowserTabsForSessions: sessionCleanupMocks.closeTrackedBrowserTabsForSessions,
|
|
}));
|
|
|
|
vi.mock("../../agents/session-write-lock.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../../agents/session-write-lock.js")>(
|
|
"../../agents/session-write-lock.js",
|
|
);
|
|
return {
|
|
...actual,
|
|
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => {} })),
|
|
resolveSessionLockMaxHoldFromTimeout: vi.fn(
|
|
({
|
|
timeoutMs,
|
|
graceMs = 2 * 60 * 1000,
|
|
minMs = 5 * 60 * 1000,
|
|
}: {
|
|
timeoutMs: number;
|
|
graceMs?: number;
|
|
minMs?: number;
|
|
}) => Math.max(minMs, timeoutMs + graceMs),
|
|
),
|
|
};
|
|
});
|
|
|
|
async function createStorePath(prefix: string): Promise<string> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`));
|
|
return path.join(root, "sessions.json");
|
|
}
|
|
|
|
async function writeStore(
|
|
storePath: string,
|
|
store: Record<string, SessionEntry | Record<string, unknown>>,
|
|
): Promise<void> {
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
await fs.writeFile(storePath, JSON.stringify(store), "utf-8");
|
|
}
|
|
|
|
async function writeTranscript(
|
|
storePath: string,
|
|
sessionId: string,
|
|
text = "hello",
|
|
): Promise<string> {
|
|
const transcriptPath = path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
|
await fs.writeFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({
|
|
type: "message",
|
|
id: `${sessionId}-m1`,
|
|
message: { role: "user", content: text },
|
|
})}\n`,
|
|
"utf-8",
|
|
);
|
|
return transcriptPath;
|
|
}
|
|
|
|
async function createStoredSession(params: {
|
|
prefix: string;
|
|
sessionKey: string;
|
|
sessionId: string;
|
|
text?: string;
|
|
updatedAt?: number;
|
|
}): Promise<{ storePath: string; transcriptPath: string }> {
|
|
const storePath = await createStorePath(params.prefix);
|
|
const transcriptPath = await writeTranscript(storePath, params.sessionId, params.text);
|
|
await writeStore(storePath, {
|
|
[params.sessionKey]: {
|
|
sessionId: params.sessionId,
|
|
sessionFile: transcriptPath,
|
|
updatedAt: params.updatedAt ?? Date.now(),
|
|
},
|
|
});
|
|
return { storePath, transcriptPath };
|
|
}
|
|
|
|
type SessionResetConfig = NonNullable<NonNullable<OpenClawConfig["session"]>["reset"]>;
|
|
|
|
async function initStoredSessionState(params: {
|
|
prefix: string;
|
|
sessionKey: string;
|
|
sessionId: string;
|
|
text: string;
|
|
updatedAt: number;
|
|
reset?: SessionResetConfig;
|
|
}): Promise<void> {
|
|
const { storePath } = await createStoredSession(params);
|
|
const cfg = {
|
|
session: {
|
|
store: storePath,
|
|
...(params.reset ? { reset: params.reset } : {}),
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
await initSessionState({
|
|
ctx: { Body: "hello", SessionKey: params.sessionKey },
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
}
|
|
|
|
function expectFields(value: unknown, expected: Record<string, unknown>): void {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error("expected fields object");
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
for (const [key, expectedValue] of Object.entries(expected)) {
|
|
expect(record[key], key).toEqual(expectedValue);
|
|
}
|
|
}
|
|
|
|
function requireHookCall(
|
|
mock: ReturnType<typeof vi.fn>,
|
|
label: string,
|
|
): readonly [Record<string, unknown>, Record<string, unknown> | undefined] {
|
|
const call = mock.mock.calls[0];
|
|
if (!call) {
|
|
throw new Error(`expected ${label} hook call`);
|
|
}
|
|
const [event, context] = call;
|
|
if (!event || typeof event !== "object") {
|
|
throw new Error(`expected ${label} hook event`);
|
|
}
|
|
if (context !== undefined && (!context || typeof context !== "object")) {
|
|
throw new Error(`expected ${label} hook context`);
|
|
}
|
|
return [event as Record<string, unknown>, context as Record<string, unknown> | undefined];
|
|
}
|
|
|
|
describe("session hook context wiring", () => {
|
|
beforeEach(() => {
|
|
hookRunnerMocks.hasHooks.mockReset();
|
|
hookRunnerMocks.runSessionStart.mockReset();
|
|
hookRunnerMocks.runSessionEnd.mockReset();
|
|
sessionCleanupMocks.closeTrackedBrowserTabsForSessions.mockClear();
|
|
sessionCleanupMocks.resetRegisteredAgentHarnessSessions.mockClear();
|
|
sessionCleanupMocks.retireSessionMcpRuntime.mockClear();
|
|
hookRunnerMocks.runSessionStart.mockResolvedValue(undefined);
|
|
hookRunnerMocks.runSessionEnd.mockResolvedValue(undefined);
|
|
hookRunnerMocks.hasHooks.mockImplementation(
|
|
(hookName) => hookName === "session_start" || hookName === "session_end",
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("passes sessionKey to session_start hook context", async () => {
|
|
const sessionKey = "agent:main:telegram:direct:123";
|
|
const storePath = await createStorePath("openclaw-session-hook-start");
|
|
await writeStore(storePath, {});
|
|
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
|
|
|
await initSessionState({
|
|
ctx: { Body: "hello", SessionKey: sessionKey },
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1);
|
|
const [event, context] = requireHookCall(hookRunnerMocks.runSessionStart, "session_start");
|
|
expectFields(event, { sessionKey });
|
|
expectFields(context, { sessionKey, agentId: "main", sessionId: event?.sessionId });
|
|
});
|
|
|
|
it("passes sessionKey to session_end hook context on reset", async () => {
|
|
const sessionKey = "agent:main:telegram:direct:123";
|
|
const { storePath } = await createStoredSession({
|
|
prefix: "openclaw-session-hook-end",
|
|
sessionKey,
|
|
sessionId: "old-session",
|
|
});
|
|
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
|
|
|
await initSessionState({
|
|
ctx: { Body: "/new", SessionKey: sessionKey },
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1);
|
|
expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1);
|
|
const [event, context] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
|
|
expectFields(event, {
|
|
sessionKey,
|
|
reason: "new",
|
|
transcriptArchived: true,
|
|
});
|
|
expectFields(context, { sessionKey, agentId: "main", sessionId: event?.sessionId });
|
|
expect(event?.sessionFile).toContain(".jsonl.reset.");
|
|
|
|
const [startEvent, startContext] = requireHookCall(
|
|
hookRunnerMocks.runSessionStart,
|
|
"session_start",
|
|
);
|
|
expectFields(startEvent, { resumedFrom: "old-session" });
|
|
expect(event?.nextSessionId).toBe(startEvent?.sessionId);
|
|
expectFields(startContext, { sessionId: startEvent?.sessionId });
|
|
});
|
|
|
|
it("marks explicit /reset rollovers with reason reset", async () => {
|
|
const sessionKey = "agent:main:telegram:direct:456";
|
|
const { storePath } = await createStoredSession({
|
|
prefix: "openclaw-session-hook-explicit-reset",
|
|
sessionKey,
|
|
sessionId: "reset-session",
|
|
text: "reset me",
|
|
});
|
|
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
|
|
|
await initSessionState({
|
|
ctx: { Body: "/reset", SessionKey: sessionKey },
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
const [event] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
|
|
expectFields(event, { reason: "reset" });
|
|
});
|
|
|
|
it("maps custom reset trigger aliases to the new-session reason", async () => {
|
|
const sessionKey = "agent:main:telegram:direct:alias";
|
|
const { storePath } = await createStoredSession({
|
|
prefix: "openclaw-session-hook-reset-alias",
|
|
sessionKey,
|
|
sessionId: "alias-session",
|
|
text: "alias me",
|
|
});
|
|
const cfg = {
|
|
session: {
|
|
store: storePath,
|
|
resetTriggers: ["/fresh"],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
await initSessionState({
|
|
ctx: { Body: "/fresh", SessionKey: sessionKey },
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
const [event] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
|
|
expectFields(event, { reason: "new" });
|
|
});
|
|
|
|
it("marks daily stale rollovers and exposes the archived transcript path", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
|
const sessionKey = "agent:main:telegram:direct:daily";
|
|
await initStoredSessionState({
|
|
prefix: "openclaw-session-hook-daily",
|
|
sessionKey,
|
|
sessionId: "daily-session",
|
|
text: "daily",
|
|
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
|
});
|
|
|
|
const [event] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
|
|
const [startEvent] = requireHookCall(hookRunnerMocks.runSessionStart, "session_start");
|
|
expectFields(event, {
|
|
reason: "daily",
|
|
transcriptArchived: true,
|
|
});
|
|
expect(event?.sessionFile).toContain(".jsonl.reset.");
|
|
expect(event?.nextSessionId).toBe(startEvent?.sessionId);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("marks idle stale rollovers with reason idle", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
|
const sessionKey = "agent:main:telegram:direct:idle";
|
|
await initStoredSessionState({
|
|
prefix: "openclaw-session-hook-idle",
|
|
sessionKey,
|
|
sessionId: "idle-session",
|
|
text: "idle",
|
|
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
|
reset: {
|
|
mode: "idle",
|
|
idleMinutes: 30,
|
|
},
|
|
});
|
|
|
|
const [event] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
|
|
expectFields(event, { reason: "idle" });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("prefers idle over daily when both rollover conditions are true", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
|
|
const sessionKey = "agent:main:telegram:direct:overlap";
|
|
await initStoredSessionState({
|
|
prefix: "openclaw-session-hook-overlap",
|
|
sessionKey,
|
|
sessionId: "overlap-session",
|
|
text: "overlap",
|
|
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
|
reset: {
|
|
mode: "daily",
|
|
atHour: 4,
|
|
idleMinutes: 30,
|
|
},
|
|
});
|
|
|
|
const [event] = requireHookCall(hookRunnerMocks.runSessionEnd, "session_end");
|
|
expectFields(event, { reason: "idle" });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|