mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 23:47:20 +00:00
perf: optimize cold import paths
This commit is contained in:
3
src/agents/subagent-registry-memory.ts
Normal file
3
src/agents/subagent-registry-memory.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
export const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||
126
src/agents/subagent-registry-read.ts
Normal file
126
src/agents/subagent-registry-read.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { SUBAGENT_ENDED_REASON_KILLED } from "./subagent-lifecycle-events.js";
|
||||
import { subagentRuns } from "./subagent-registry-memory.js";
|
||||
import { listRunsForControllerFromRuns } from "./subagent-registry-queries.js";
|
||||
import { getSubagentRunsSnapshotForRead } from "./subagent-registry-state.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
function resolveSubagentSessionStartedAt(
|
||||
entry: Pick<SubagentRunRecord, "sessionStartedAt" | "startedAt" | "createdAt">,
|
||||
): number | undefined {
|
||||
if (typeof entry.sessionStartedAt === "number" && Number.isFinite(entry.sessionStartedAt)) {
|
||||
return entry.sessionStartedAt;
|
||||
}
|
||||
if (typeof entry.startedAt === "number" && Number.isFinite(entry.startedAt)) {
|
||||
return entry.startedAt;
|
||||
}
|
||||
return typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)
|
||||
? entry.createdAt
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function getSubagentSessionStartedAt(
|
||||
entry: Pick<SubagentRunRecord, "sessionStartedAt" | "startedAt" | "createdAt"> | null | undefined,
|
||||
): number | undefined {
|
||||
return entry ? resolveSubagentSessionStartedAt(entry) : undefined;
|
||||
}
|
||||
|
||||
export function getSubagentSessionRuntimeMs(
|
||||
entry:
|
||||
| Pick<SubagentRunRecord, "startedAt" | "endedAt" | "accumulatedRuntimeMs">
|
||||
| null
|
||||
| undefined,
|
||||
now = Date.now(),
|
||||
): number | undefined {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accumulatedRuntimeMs =
|
||||
typeof entry.accumulatedRuntimeMs === "number" && Number.isFinite(entry.accumulatedRuntimeMs)
|
||||
? Math.max(0, entry.accumulatedRuntimeMs)
|
||||
: 0;
|
||||
|
||||
if (typeof entry.startedAt !== "number" || !Number.isFinite(entry.startedAt)) {
|
||||
return entry.accumulatedRuntimeMs != null ? accumulatedRuntimeMs : undefined;
|
||||
}
|
||||
|
||||
const currentRunEndedAt =
|
||||
typeof entry.endedAt === "number" && Number.isFinite(entry.endedAt) ? entry.endedAt : now;
|
||||
return Math.max(0, accumulatedRuntimeMs + Math.max(0, currentRunEndedAt - entry.startedAt));
|
||||
}
|
||||
|
||||
export function resolveSubagentSessionStatus(
|
||||
entry: Pick<SubagentRunRecord, "endedAt" | "endedReason" | "outcome"> | null | undefined,
|
||||
): "running" | "killed" | "failed" | "timeout" | "done" | undefined {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (!entry.endedAt) {
|
||||
return "running";
|
||||
}
|
||||
if (entry.endedReason === SUBAGENT_ENDED_REASON_KILLED) {
|
||||
return "killed";
|
||||
}
|
||||
const status = entry.outcome?.status;
|
||||
if (status === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (status === "timeout") {
|
||||
return "timeout";
|
||||
}
|
||||
return "done";
|
||||
}
|
||||
|
||||
export function listSubagentRunsForController(controllerSessionKey: string): SubagentRunRecord[] {
|
||||
return listRunsForControllerFromRuns(
|
||||
getSubagentRunsSnapshotForRead(subagentRuns),
|
||||
controllerSessionKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSubagentRunByChildSessionKey(childSessionKey: string): SubagentRunRecord | null {
|
||||
const key = childSessionKey.trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latestActive: SubagentRunRecord | null = null;
|
||||
let latestEnded: SubagentRunRecord | null = null;
|
||||
for (const entry of getSubagentRunsSnapshotForRead(subagentRuns).values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.endedAt !== "number") {
|
||||
if (!latestActive || entry.createdAt > latestActive.createdAt) {
|
||||
latestActive = entry;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!latestEnded || entry.createdAt > latestEnded.createdAt) {
|
||||
latestEnded = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return latestActive ?? latestEnded;
|
||||
}
|
||||
|
||||
export function getLatestSubagentRunByChildSessionKey(
|
||||
childSessionKey: string,
|
||||
): SubagentRunRecord | null {
|
||||
const key = childSessionKey.trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latest: SubagentRunRecord | null = null;
|
||||
for (const entry of getSubagentRunsSnapshotForRead(subagentRuns).values()) {
|
||||
if (entry.childSessionKey !== key) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || entry.createdAt > latest.createdAt) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
resolveLifecycleOutcomeFromRunOutcome,
|
||||
runOutcomesEqual,
|
||||
} from "./subagent-registry-completion.js";
|
||||
import { subagentRuns } from "./subagent-registry-memory.js";
|
||||
import {
|
||||
countActiveDescendantRunsFromRuns,
|
||||
countActiveRunsForSessionFromRuns,
|
||||
@@ -64,7 +65,6 @@ import { resolveAgentTimeoutMs } from "./timeout.js";
|
||||
export type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
const log = createSubsystemLogger("agents/subagent-registry");
|
||||
|
||||
const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||
let sweeper: NodeJS.Timeout | null = null;
|
||||
let listenerStarted = false;
|
||||
let listenerStop: (() => void) | null = null;
|
||||
|
||||
@@ -788,7 +788,7 @@ describe("mention helpers", () => {
|
||||
});
|
||||
|
||||
describe("resolveGroupRequireMention", () => {
|
||||
it("respects Discord guild/channel requireMention settings", () => {
|
||||
it("respects Discord guild/channel requireMention settings", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -816,10 +816,10 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", () => {
|
||||
it("respects Slack channel requireMention settings", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -842,10 +842,10 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("uses Slack fallback resolver semantics for default-account wildcard channels", () => {
|
||||
it("uses Slack fallback resolver semantics for default-account wildcard channels", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -873,10 +873,10 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("matches the Slack plugin resolver for default-account wildcard fallbacks", () => {
|
||||
it("matches the Slack plugin resolver for default-account wildcard fallbacks", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -904,7 +904,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(
|
||||
resolveSlackGroupRequireMention({
|
||||
cfg,
|
||||
groupId: groupResolution.id,
|
||||
@@ -913,7 +913,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Discord fallback resolver semantics for guild slug matches", () => {
|
||||
it("uses Discord fallback resolver semantics for guild slug matches", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -940,10 +940,10 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("matches the Discord plugin resolver for slug + wildcard guild fallbacks", () => {
|
||||
it("matches the Discord plugin resolver for slug + wildcard guild fallbacks", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -972,7 +972,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(
|
||||
resolveDiscordGroupRequireMention({
|
||||
cfg,
|
||||
groupId: groupResolution.id,
|
||||
@@ -982,7 +982,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("respects LINE prefixed group keys in reply-stage requireMention resolution", () => {
|
||||
it("respects LINE prefixed group keys in reply-stage requireMention resolution", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -1004,10 +1004,10 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("preserves plugin-backed channel requireMention resolution", () => {
|
||||
it("preserves plugin-backed channel requireMention resolution", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
@@ -1029,6 +1029,6 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,10 @@ vi.mock("./reply/directive-handling.defaults.js", () => ({
|
||||
vi.mock("./reply/inbound-context.js", () => ({
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
}));
|
||||
vi.mock("./reply/session-reset-model.js", () => ({
|
||||
vi.mock("./reply/session-reset-model.runtime.js", () => ({
|
||||
applyResetModelOverride: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("./reply/stage-sandbox-media.js", () => ({
|
||||
vi.mock("./reply/stage-sandbox-media.runtime.js", () => ({
|
||||
stageSandboxMedia: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("./reply/typing.js", () => ({
|
||||
|
||||
1
src/auto-reply/reply.runtime.ts
Normal file
1
src/auto-reply/reply.runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getReplyFromConfig } from "./reply.js";
|
||||
@@ -15,7 +15,13 @@ const childProcessMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../agents/sandbox.js", () => sandboxMocks);
|
||||
vi.mock("node:child_process", () => childProcessMocks);
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: childProcessMocks.spawn,
|
||||
};
|
||||
});
|
||||
|
||||
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSand
|
||||
async function loadFreshStageSandboxMediaModuleForTest() {
|
||||
vi.resetModules();
|
||||
vi.doMock(sandboxModuleId, () => sandboxMocks);
|
||||
vi.doMock("node:child_process", () => childProcessMocks);
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: childProcessMocks.spawn,
|
||||
};
|
||||
});
|
||||
vi.doMock(fsSafeModuleId, async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/fs-safe.js")>();
|
||||
return {
|
||||
|
||||
@@ -361,7 +361,7 @@ export async function resolveReplyDirectives(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const requireMention = resolveGroupRequireMention({
|
||||
const requireMention = await resolveGroupRequireMention({
|
||||
cfg,
|
||||
ctx: sessionCtx,
|
||||
groupResolution,
|
||||
|
||||
@@ -20,9 +20,15 @@ vi.mock("../../config/sessions/paths.js", () => ({
|
||||
resolveSessionFilePathOptions: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions/store.js", () => ({
|
||||
updateSessionStore: vi.fn(),
|
||||
}));
|
||||
const storeRuntimeLoads = vi.hoisted(() => vi.fn());
|
||||
const updateSessionStore = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/sessions/store.runtime.js", () => {
|
||||
storeRuntimeLoads();
|
||||
return {
|
||||
updateSessionStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../globals.js", () => ({
|
||||
logVerbose: vi.fn(),
|
||||
@@ -181,10 +187,18 @@ function baseParams(
|
||||
|
||||
describe("runPreparedReply media-only handling", () => {
|
||||
beforeEach(async () => {
|
||||
storeRuntimeLoads.mockClear();
|
||||
updateSessionStore.mockReset();
|
||||
vi.clearAllMocks();
|
||||
await loadFreshGetReplyRunModuleForTest();
|
||||
});
|
||||
|
||||
it("does not load session store runtime on module import", async () => {
|
||||
await loadFreshGetReplyRunModuleForTest();
|
||||
|
||||
expect(storeRuntimeLoads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows media-only prompts and preserves thread context in queued followups", async () => {
|
||||
const result = await runPreparedReply(baseParams());
|
||||
expect(result).toEqual({ text: "ok" });
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
} from "../../config/sessions/paths.js";
|
||||
import { updateSessionStore } from "../../config/sessions/store.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||
@@ -54,6 +53,9 @@ let agentRunnerRuntimePromise: Promise<typeof import("./agent-runner.runtime.js"
|
||||
let routeReplyRuntimePromise: Promise<typeof import("./route-reply.runtime.js")> | null = null;
|
||||
let sessionUpdatesRuntimePromise: Promise<typeof import("./session-updates.runtime.js")> | null =
|
||||
null;
|
||||
let sessionStoreRuntimePromise: Promise<
|
||||
typeof import("../../config/sessions/store.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
function loadPiEmbeddedRuntime() {
|
||||
piEmbeddedRuntimePromise ??= import("../../agents/pi-embedded.runtime.js");
|
||||
@@ -75,6 +77,11 @@ function loadSessionUpdatesRuntime() {
|
||||
return sessionUpdatesRuntimePromise;
|
||||
}
|
||||
|
||||
function loadSessionStoreRuntime() {
|
||||
sessionStoreRuntimePromise ??= import("../../config/sessions/store.runtime.js");
|
||||
return sessionStoreRuntimePromise;
|
||||
}
|
||||
|
||||
function buildResetSessionNoticeText(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -439,6 +446,7 @@ export async function runPreparedReply(
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
const { updateSessionStore } = await loadSessionStoreRuntime();
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
|
||||
27
src/auto-reply/reply/get-reply.imports.test.ts
Normal file
27
src/auto-reply/reply/get-reply.imports.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("get-reply module imports", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("does not load reset-model runtime on module import", async () => {
|
||||
const resetModelRuntimeLoads = vi.fn();
|
||||
const sandboxMediaRuntimeLoads = vi.fn();
|
||||
vi.doMock("./session-reset-model.runtime.js", async (importOriginal) => {
|
||||
resetModelRuntimeLoads();
|
||||
return await importOriginal<typeof import("./session-reset-model.runtime.js")>();
|
||||
});
|
||||
vi.doMock("./stage-sandbox-media.runtime.js", async (importOriginal) => {
|
||||
sandboxMediaRuntimeLoads();
|
||||
return await importOriginal<typeof import("./stage-sandbox-media.runtime.js")>();
|
||||
});
|
||||
|
||||
await import("./get-reply.js");
|
||||
|
||||
expect(resetModelRuntimeLoads).not.toHaveBeenCalled();
|
||||
expect(sandboxMediaRuntimeLoads).not.toHaveBeenCalled();
|
||||
vi.doUnmock("./session-reset-model.runtime.js");
|
||||
vi.doUnmock("./stage-sandbox-media.runtime.js");
|
||||
});
|
||||
});
|
||||
@@ -51,10 +51,10 @@ export function registerGetReplyCommonMocks(): void {
|
||||
vi.mock("./inbound-context.js", () => ({
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
}));
|
||||
vi.mock("./session-reset-model.js", () => ({
|
||||
vi.mock("./session-reset-model.runtime.js", () => ({
|
||||
applyResetModelOverride: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("./stage-sandbox-media.js", () => ({
|
||||
vi.mock("./stage-sandbox-media.runtime.js", () => ({
|
||||
stageSandboxMedia: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("./typing.js", () => ({
|
||||
|
||||
@@ -22,13 +22,28 @@ import { handleInlineActions } from "./get-reply-inline-actions.js";
|
||||
import { runPreparedReply } from "./get-reply-run.js";
|
||||
import { finalizeInboundContext } from "./inbound-context.js";
|
||||
import { emitPreAgentMessageHooks } from "./message-preprocess-hooks.js";
|
||||
import { applyResetModelOverride } from "./session-reset-model.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
import { stageSandboxMedia } from "./stage-sandbox-media.js";
|
||||
import { createTypingController } from "./typing.js";
|
||||
|
||||
type ResetCommandAction = "new" | "reset";
|
||||
|
||||
let sessionResetModelRuntimePromise: Promise<
|
||||
typeof import("./session-reset-model.runtime.js")
|
||||
> | null = null;
|
||||
let stageSandboxMediaRuntimePromise: Promise<
|
||||
typeof import("./stage-sandbox-media.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
function loadSessionResetModelRuntime() {
|
||||
sessionResetModelRuntimePromise ??= import("./session-reset-model.runtime.js");
|
||||
return sessionResetModelRuntimePromise;
|
||||
}
|
||||
|
||||
function loadStageSandboxMediaRuntime() {
|
||||
stageSandboxMediaRuntimePromise ??= import("./stage-sandbox-media.runtime.js");
|
||||
return stageSandboxMediaRuntimePromise;
|
||||
}
|
||||
|
||||
function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): string[] | undefined {
|
||||
const normalize = (list?: string[]) => {
|
||||
if (!Array.isArray(list)) {
|
||||
@@ -222,21 +237,24 @@ export async function getReplyFromConfig(
|
||||
bodyStripped,
|
||||
} = sessionState;
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
agentId,
|
||||
resetTriggered,
|
||||
bodyStripped,
|
||||
sessionCtx,
|
||||
ctx: finalized,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resetTriggered && bodyStripped?.trim()) {
|
||||
const { applyResetModelOverride } = await loadSessionResetModelRuntime();
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
agentId,
|
||||
resetTriggered,
|
||||
bodyStripped,
|
||||
sessionCtx,
|
||||
ctx: finalized,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const channelModelOverride = resolveChannelModelOverride({
|
||||
cfg,
|
||||
@@ -400,13 +418,16 @@ export async function getReplyFromConfig(
|
||||
directives = inlineActionResult.directives;
|
||||
abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun;
|
||||
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
if (sessionKey && hasInboundMedia(ctx)) {
|
||||
const { stageSandboxMedia } = await loadStageSandboxMediaRuntime();
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
sessionCtx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
return runPreparedReply({
|
||||
ctx,
|
||||
|
||||
3
src/auto-reply/reply/groups.runtime.ts
Normal file
3
src/auto-reply/reply/groups.runtime.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { resolveDiscordGroupRequireMention } from "../../../extensions/discord/api.js";
|
||||
export { resolveSlackGroupRequireMention } from "../../../extensions/slack/api.js";
|
||||
export { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
76
src/auto-reply/reply/groups.test.ts
Normal file
76
src/auto-reply/reply/groups.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resetPluginRuntimeStateForTest } from "../../plugins/runtime.js";
|
||||
|
||||
describe("group runtime loading", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("keeps prompt helpers off the heavy group runtime", async () => {
|
||||
const groupsRuntimeLoads = vi.fn();
|
||||
vi.doMock("./groups.runtime.js", async (importOriginal) => {
|
||||
groupsRuntimeLoads();
|
||||
return await importOriginal<typeof import("./groups.runtime.js")>();
|
||||
});
|
||||
const groups = await import("./groups.js");
|
||||
|
||||
expect(groupsRuntimeLoads).not.toHaveBeenCalled();
|
||||
expect(
|
||||
groups.buildGroupChatContext({
|
||||
sessionCtx: {
|
||||
ChatType: "group",
|
||||
GroupSubject: "Ops",
|
||||
Provider: "whatsapp",
|
||||
},
|
||||
}),
|
||||
).toContain('You are in the WhatsApp group chat "Ops".');
|
||||
expect(
|
||||
groups.buildGroupIntro({
|
||||
cfg: {} as OpenClawConfig,
|
||||
sessionCtx: { Provider: "whatsapp" },
|
||||
defaultActivation: "mention",
|
||||
silentToken: "NO_REPLY",
|
||||
}),
|
||||
).toContain("WhatsApp IDs:");
|
||||
expect(groupsRuntimeLoads).not.toHaveBeenCalled();
|
||||
vi.doUnmock("./groups.runtime.js");
|
||||
});
|
||||
|
||||
it("loads the group runtime only when requireMention resolution needs it", async () => {
|
||||
const groupsRuntimeLoads = vi.fn();
|
||||
vi.doMock("./groups.runtime.js", async (importOriginal) => {
|
||||
groupsRuntimeLoads();
|
||||
return await importOriginal<typeof import("./groups.runtime.js")>();
|
||||
});
|
||||
const groups = await import("./groups.js");
|
||||
|
||||
await expect(
|
||||
groups.resolveGroupRequireMention({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
channels: {
|
||||
C123: { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ctx: {
|
||||
Provider: "slack",
|
||||
From: "slack:channel:C123",
|
||||
GroupSubject: "#general",
|
||||
},
|
||||
groupResolution: {
|
||||
key: "slack:group:C123",
|
||||
channel: "slack",
|
||||
id: "C123",
|
||||
chatType: "group",
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(groupsRuntimeLoads).toHaveBeenCalled();
|
||||
vi.doUnmock("./groups.runtime.js");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,4 @@
|
||||
import { resolveDiscordGroupRequireMention } from "../../../extensions/discord/api.js";
|
||||
import { resolveSlackGroupRequireMention } from "../../../extensions/slack/api.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
normalizeChannelId as normalizePluginChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { resolveWhatsAppGroupIntroHint } from "../../channels/plugins/whatsapp-shared.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
|
||||
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
|
||||
@@ -14,56 +7,88 @@ import { normalizeGroupActivation } from "../group-activation.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { extractExplicitGroupId } from "./group-id.js";
|
||||
|
||||
const WHATSAPP_GROUP_INTRO_HINT =
|
||||
"WhatsApp IDs: SenderId is the participant JID (group participant id).";
|
||||
|
||||
const CHANNEL_LABELS: Partial<Record<ChannelId, string>> = {
|
||||
bluebubbles: "BlueBubbles",
|
||||
discord: "Discord",
|
||||
imessage: "iMessage",
|
||||
line: "LINE",
|
||||
signal: "Signal",
|
||||
slack: "Slack",
|
||||
telegram: "Telegram",
|
||||
webchat: "WebChat",
|
||||
whatsapp: "WhatsApp",
|
||||
};
|
||||
|
||||
let groupsRuntimePromise: Promise<typeof import("./groups.runtime.js")> | null = null;
|
||||
|
||||
function loadGroupsRuntime() {
|
||||
groupsRuntimePromise ??= import("./groups.runtime.js");
|
||||
return groupsRuntimePromise;
|
||||
}
|
||||
|
||||
function resolveGroupId(raw: string | undefined | null): string | undefined {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
return extractExplicitGroupId(trimmed) ?? (trimmed || undefined);
|
||||
}
|
||||
|
||||
function resolveDockChannelId(raw?: string | null): ChannelId | null {
|
||||
function resolveLooseChannelId(raw?: string | null): ChannelId | null {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return normalized as ChannelId;
|
||||
}
|
||||
|
||||
async function resolveRuntimeChannelId(raw?: string | null): Promise<ChannelId | null> {
|
||||
const normalized = resolveLooseChannelId(raw);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const { getChannelPlugin, normalizeChannelId } = await loadGroupsRuntime();
|
||||
try {
|
||||
if (getChannelPlugin(normalized as ChannelId)) {
|
||||
return normalized as ChannelId;
|
||||
if (getChannelPlugin(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
} catch {
|
||||
// Plugin registry may not be initialized in shared/test contexts.
|
||||
}
|
||||
try {
|
||||
return normalizePluginChannelId(raw) ?? (normalized as ChannelId);
|
||||
return normalizeChannelId(raw) ?? normalized;
|
||||
} catch {
|
||||
return normalized as ChannelId;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBuiltInRequireMentionFromConfig(params: {
|
||||
async function resolveBuiltInRequireMentionFromConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ChannelId;
|
||||
groupChannel?: string;
|
||||
groupId?: string;
|
||||
groupSpace?: string;
|
||||
accountId?: string | null;
|
||||
}): boolean | undefined {
|
||||
}): Promise<boolean | undefined> {
|
||||
const runtime = await loadGroupsRuntime();
|
||||
switch (params.channel) {
|
||||
case "discord":
|
||||
return resolveDiscordGroupRequireMention(params);
|
||||
return runtime.resolveDiscordGroupRequireMention(params);
|
||||
case "slack":
|
||||
return resolveSlackGroupRequireMention(params);
|
||||
return runtime.resolveSlackGroupRequireMention(params);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGroupRequireMention(params: {
|
||||
export async function resolveGroupRequireMention(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: TemplateContext;
|
||||
groupResolution?: GroupKeyResolution;
|
||||
}): boolean {
|
||||
}): Promise<boolean> {
|
||||
const { cfg, ctx, groupResolution } = params;
|
||||
const rawChannel = groupResolution?.channel ?? ctx.Provider?.trim();
|
||||
const channel = resolveDockChannelId(rawChannel);
|
||||
const channel = await resolveRuntimeChannelId(rawChannel);
|
||||
if (!channel) {
|
||||
return true;
|
||||
}
|
||||
@@ -71,8 +96,9 @@ export function resolveGroupRequireMention(params: {
|
||||
const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
|
||||
const groupSpace = ctx.GroupSpace?.trim();
|
||||
let requireMention: boolean | undefined;
|
||||
const runtime = await loadGroupsRuntime();
|
||||
try {
|
||||
requireMention = getChannelPlugin(channel)?.groups?.resolveRequireMention?.({
|
||||
requireMention = runtime.getChannelPlugin(channel)?.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
groupId,
|
||||
groupChannel,
|
||||
@@ -85,7 +111,7 @@ export function resolveGroupRequireMention(params: {
|
||||
if (typeof requireMention === "boolean") {
|
||||
return requireMention;
|
||||
}
|
||||
const builtInRequireMention = resolveBuiltInRequireMentionFromConfig({
|
||||
const builtInRequireMention = await resolveBuiltInRequireMentionFromConfig({
|
||||
cfg,
|
||||
channel,
|
||||
groupChannel,
|
||||
@@ -108,9 +134,6 @@ export function defaultGroupActivation(requireMention: boolean): "always" | "men
|
||||
return !requireMention ? "always" : "mention";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a human-readable provider label from the raw provider string.
|
||||
*/
|
||||
function resolveProviderLabel(rawProvider: string | undefined): string {
|
||||
const providerKey = rawProvider?.trim().toLowerCase() ?? "";
|
||||
if (!providerKey) {
|
||||
@@ -119,20 +142,13 @@ function resolveProviderLabel(rawProvider: string | undefined): string {
|
||||
if (isInternalMessageChannel(providerKey)) {
|
||||
return "WebChat";
|
||||
}
|
||||
const providerId = resolveDockChannelId(rawProvider?.trim());
|
||||
const providerId = resolveLooseChannelId(rawProvider?.trim());
|
||||
if (providerId) {
|
||||
return getChannelPlugin(providerId)?.meta.label ?? providerId;
|
||||
return CHANNEL_LABELS[providerId] ?? providerId;
|
||||
}
|
||||
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a persistent group-chat context block that is always included in the
|
||||
* system prompt for group-chat sessions (every turn, not just the first).
|
||||
*
|
||||
* Contains: group name, participants, and an explicit instruction to reply
|
||||
* directly instead of using the message tool.
|
||||
*/
|
||||
export function buildGroupChatContext(params: { sessionCtx: TemplateContext }): string {
|
||||
const subject = params.sessionCtx.GroupSubject?.trim();
|
||||
const members = params.sessionCtx.GroupMembers?.trim();
|
||||
@@ -162,25 +178,12 @@ export function buildGroupIntro(params: {
|
||||
}): string {
|
||||
const activation =
|
||||
normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation;
|
||||
const rawProvider = params.sessionCtx.Provider?.trim();
|
||||
const providerId = resolveDockChannelId(rawProvider);
|
||||
const providerId = resolveLooseChannelId(params.sessionCtx.Provider?.trim());
|
||||
const activationLine =
|
||||
activation === "always"
|
||||
? "Activation: always-on (you receive every group message)."
|
||||
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
|
||||
const groupId = params.sessionEntry?.groupId ?? resolveGroupId(params.sessionCtx.From);
|
||||
const groupChannel =
|
||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim();
|
||||
const groupSpace = params.sessionCtx.GroupSpace?.trim();
|
||||
const providerIdsLine = providerId
|
||||
? (getChannelPlugin(providerId)?.groups?.resolveGroupIntroHint?.({
|
||||
cfg: params.cfg,
|
||||
groupId,
|
||||
groupChannel,
|
||||
groupSpace,
|
||||
accountId: params.sessionCtx.AccountId,
|
||||
}) ?? (providerId === "whatsapp" ? resolveWhatsAppGroupIntroHint() : undefined))
|
||||
: undefined;
|
||||
const providerIdsLine = providerId === "whatsapp" ? WHATSAPP_GROUP_INTRO_HINT : undefined;
|
||||
const silenceLine =
|
||||
activation === "always"
|
||||
? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { compileConfigRegexes, type ConfigRegexRejectReason } from "../../security/config-regex.js";
|
||||
@@ -208,7 +209,10 @@ export function stripMentions(
|
||||
agentId?: string,
|
||||
): string {
|
||||
let result = text;
|
||||
const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null;
|
||||
const providerId =
|
||||
(ctx.Provider ? normalizeChannelId(ctx.Provider) : null) ??
|
||||
(ctx.Provider?.trim().toLowerCase() as ChannelId | undefined) ??
|
||||
null;
|
||||
const providerMentions = providerId ? getChannelPlugin(providerId)?.mentions : undefined;
|
||||
const configRegexes = compileMentionPatternsCached({
|
||||
patterns: normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId)),
|
||||
|
||||
1
src/auto-reply/reply/session-reset-model.runtime.ts
Normal file
1
src/auto-reply/reply/session-reset-model.runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { applyResetModelOverride } from "./session-reset-model.js";
|
||||
20
src/auto-reply/reply/session.imports.test.ts
Normal file
20
src/auto-reply/reply/session.imports.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("reply session module imports", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("does not load archive runtime on module import", async () => {
|
||||
const archiveRuntimeLoads = vi.fn();
|
||||
vi.doMock("../../gateway/session-archive.runtime.js", async (importOriginal) => {
|
||||
archiveRuntimeLoads();
|
||||
return await importOriginal<typeof import("../../gateway/session-archive.runtime.js")>();
|
||||
});
|
||||
|
||||
await import("./session.js");
|
||||
|
||||
expect(archiveRuntimeLoads).not.toHaveBeenCalled();
|
||||
vi.doUnmock("../../gateway/session-archive.runtime.js");
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type SessionScope,
|
||||
} from "../../config/sessions/types.js";
|
||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||
import { archiveSessionTranscripts } from "../../gateway/session-archive.fs.js";
|
||||
import { resolveConversationIdFromTargets } from "../../infra/outbound/conversation-id.js";
|
||||
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
@@ -51,6 +50,14 @@ import { forkSessionFromParent, resolveParentForkMaxTokens } from "./session-for
|
||||
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
|
||||
|
||||
const log = createSubsystemLogger("session-init");
|
||||
let sessionArchiveRuntimePromise: Promise<
|
||||
typeof import("../../gateway/session-archive.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
function loadSessionArchiveRuntime() {
|
||||
sessionArchiveRuntimePromise ??= import("../../gateway/session-archive.runtime.js");
|
||||
return sessionArchiveRuntimePromise;
|
||||
}
|
||||
|
||||
export type SessionInitResult = {
|
||||
sessionCtx: TemplateContext;
|
||||
@@ -573,6 +580,7 @@ export async function initSessionState(params: {
|
||||
|
||||
// Archive old transcript so it doesn't accumulate on disk (#14869).
|
||||
if (previousSessionEntry?.sessionId) {
|
||||
const { archiveSessionTranscripts } = await loadSessionArchiveRuntime();
|
||||
archiveSessionTranscripts({
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
storePath,
|
||||
|
||||
1
src/auto-reply/reply/stage-sandbox-media.runtime.ts
Normal file
1
src/auto-reply/reply/stage-sandbox-media.runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { stageSandboxMedia } from "./stage-sandbox-media.js";
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDefaultDeps } from "./deps.js";
|
||||
|
||||
const moduleLoads = vi.hoisted(() => ({
|
||||
whatsapp: vi.fn(),
|
||||
@@ -19,6 +18,13 @@ const sendFns = vi.hoisted(() => ({
|
||||
imessage: vi.fn(async () => ({ messageId: "i1", chatId: "imessage:1" })),
|
||||
}));
|
||||
|
||||
const whatsappBoundaryLoads = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../plugins/runtime/runtime-whatsapp-boundary.js", async (importOriginal) => {
|
||||
whatsappBoundaryLoads();
|
||||
return await importOriginal<typeof import("../plugins/runtime/runtime-whatsapp-boundary.js")>();
|
||||
});
|
||||
|
||||
vi.mock("./send-runtime/whatsapp.js", () => {
|
||||
moduleLoads.whatsapp();
|
||||
return { runtimeSend: { sendMessage: sendFns.whatsapp } };
|
||||
@@ -50,6 +56,10 @@ vi.mock("./send-runtime/imessage.js", () => {
|
||||
});
|
||||
|
||||
describe("createDefaultDeps", () => {
|
||||
async function loadCreateDefaultDeps() {
|
||||
return (await import("./deps.js")).createDefaultDeps;
|
||||
}
|
||||
|
||||
function expectUnusedModulesNotLoaded(exclude: keyof typeof moduleLoads): void {
|
||||
const keys = Object.keys(moduleLoads) as Array<keyof typeof moduleLoads>;
|
||||
for (const key of keys) {
|
||||
@@ -62,9 +72,11 @@ describe("createDefaultDeps", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("does not load provider modules until a dependency is used", async () => {
|
||||
const createDefaultDeps = await loadCreateDefaultDeps();
|
||||
const deps = createDefaultDeps();
|
||||
|
||||
expect(moduleLoads.whatsapp).not.toHaveBeenCalled();
|
||||
@@ -83,6 +95,7 @@ describe("createDefaultDeps", () => {
|
||||
});
|
||||
|
||||
it("reuses module cache after first dynamic import", async () => {
|
||||
const createDefaultDeps = await loadCreateDefaultDeps();
|
||||
const deps = createDefaultDeps();
|
||||
const sendDiscord = deps["discord"] as (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
@@ -92,4 +105,10 @@ describe("createDefaultDeps", () => {
|
||||
expect(moduleLoads.discord).toHaveBeenCalledTimes(1);
|
||||
expect(sendFns.discord).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not import the whatsapp runtime boundary on deps module load", async () => {
|
||||
await import("./deps.js");
|
||||
|
||||
expect(whatsappBoundaryLoads).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ function createLazySender(
|
||||
}
|
||||
|
||||
export function createDefaultDeps(): CliDeps {
|
||||
// Keep the default dependency barrel limited to lazy senders so callers that
|
||||
// only need outbound deps do not pull channel runtime boundaries on import.
|
||||
return {
|
||||
whatsapp: createLazySender(
|
||||
"whatsapp",
|
||||
@@ -69,5 +71,3 @@ export function createDefaultDeps(): CliDeps {
|
||||
export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps {
|
||||
return createOutboundSendDepsFromCliSource(deps);
|
||||
}
|
||||
|
||||
export { logWebSelfId } from "../plugins/runtime/runtime-whatsapp-boundary.js";
|
||||
|
||||
20
src/config/sessions.store.imports.test.ts
Normal file
20
src/config/sessions.store.imports.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("session store module imports", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("does not load archive runtime on module import", async () => {
|
||||
const archiveRuntimeLoads = vi.fn();
|
||||
vi.doMock("../gateway/session-archive.runtime.js", async (importOriginal) => {
|
||||
archiveRuntimeLoads();
|
||||
return await importOriginal<typeof import("../gateway/session-archive.runtime.js")>();
|
||||
});
|
||||
|
||||
await import("./sessions/store.js");
|
||||
|
||||
expect(archiveRuntimeLoads).not.toHaveBeenCalled();
|
||||
vi.doUnmock("../gateway/session-archive.runtime.js");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { acquireSessionWriteLock } from "../../agents/session-write-lock.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import {
|
||||
archiveSessionTranscripts,
|
||||
cleanupArchivedSessionTranscripts,
|
||||
} from "../../gateway/session-archive.fs.js";
|
||||
import { writeTextAtomic } from "../../infra/json-files.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import {
|
||||
@@ -45,6 +41,14 @@ import {
|
||||
} from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("sessions/store");
|
||||
let sessionArchiveRuntimePromise: Promise<
|
||||
typeof import("../../gateway/session-archive.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
function loadSessionArchiveRuntime() {
|
||||
sessionArchiveRuntimePromise ??= import("../../gateway/session-archive.runtime.js");
|
||||
return sessionArchiveRuntimePromise;
|
||||
}
|
||||
|
||||
function isSessionStoreRecord(value: unknown): value is Record<string, SessionEntry> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -472,7 +476,7 @@ async function saveSessionStoreUnlocked(
|
||||
.map((entry) => entry?.sessionId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const archivedForDeletedSessions = archiveRemovedSessionTranscripts({
|
||||
const archivedForDeletedSessions = await archiveRemovedSessionTranscripts({
|
||||
removedSessionFiles,
|
||||
referencedSessionIds,
|
||||
storePath,
|
||||
@@ -483,6 +487,7 @@ async function saveSessionStoreUnlocked(
|
||||
archivedDirs.add(archivedDir);
|
||||
}
|
||||
if (archivedDirs.size > 0 || maintenance.resetArchiveRetentionMs != null) {
|
||||
const { cleanupArchivedSessionTranscripts } = await loadSessionArchiveRuntime();
|
||||
const targetDirs =
|
||||
archivedDirs.size > 0 ? [...archivedDirs] : [path.dirname(path.resolve(storePath))];
|
||||
await cleanupArchivedSessionTranscripts({
|
||||
@@ -643,13 +648,14 @@ function rememberRemovedSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function archiveRemovedSessionTranscripts(params: {
|
||||
export async function archiveRemovedSessionTranscripts(params: {
|
||||
removedSessionFiles: Iterable<[string, string | undefined]>;
|
||||
referencedSessionIds: ReadonlySet<string>;
|
||||
storePath: string;
|
||||
reason: "deleted" | "reset";
|
||||
restrictToStoreDir?: boolean;
|
||||
}): Set<string> {
|
||||
}): Promise<Set<string>> {
|
||||
const { archiveSessionTranscripts } = await loadSessionArchiveRuntime();
|
||||
const archivedDirs = new Set<string>();
|
||||
for (const [sessionId, sessionFile] of params.removedSessionFiles) {
|
||||
if (params.referencedSessionIds.has(sessionId)) {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function sweepCronRunSessions(params: {
|
||||
.map((entry) => entry?.sessionId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const archivedDirs = archiveRemovedSessionTranscripts({
|
||||
const archivedDirs = await archiveRemovedSessionTranscripts({
|
||||
removedSessionFiles: prunedSessions,
|
||||
referencedSessionIds,
|
||||
storePath,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as extensionApi from "openclaw/extension-api";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("extension-api compat surface", () => {
|
||||
it("keeps legacy agent helpers importable", () => {
|
||||
expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function");
|
||||
expect(typeof extensionApi.resolveAgentDir).toBe("function");
|
||||
expect(typeof extensionApi.resolveAgentWorkspaceDir).toBe("function");
|
||||
expect(typeof extensionApi.resolveAgentTimeoutMs).toBe("function");
|
||||
expect(typeof extensionApi.ensureAgentWorkspace).toBe("function");
|
||||
});
|
||||
|
||||
it("keeps legacy defaults and session helpers importable", () => {
|
||||
expect(typeof extensionApi.DEFAULT_MODEL).toBe("string");
|
||||
expect(typeof extensionApi.DEFAULT_PROVIDER).toBe("string");
|
||||
expect(typeof extensionApi.resolveStorePath).toBe("function");
|
||||
expect(typeof extensionApi.loadSessionStore).toBe("function");
|
||||
expect(typeof extensionApi.saveSessionStore).toBe("function");
|
||||
expect(typeof extensionApi.resolveSessionFilePath).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -67,8 +67,11 @@ vi.mock("../../infra/agent-events.js", () => ({
|
||||
onAgentEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/subagent-registry.js", () => ({
|
||||
vi.mock("../../agents/subagent-registry-read.js", () => ({
|
||||
getLatestSubagentRunByChildSessionKey: mocks.getLatestSubagentRunByChildSessionKey,
|
||||
}));
|
||||
|
||||
vi.mock("../session-subagent-reactivation.runtime.js", () => ({
|
||||
replaceSubagentRunAfterSteer: mocks.replaceSubagentRunAfterSteer,
|
||||
}));
|
||||
|
||||
|
||||
@@ -656,7 +656,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
respond(true, accepted, undefined, { runId });
|
||||
|
||||
if (resolvedSessionKey) {
|
||||
reactivateCompletedSubagentSession({
|
||||
await reactivateCompletedSubagentSession({
|
||||
sessionKey: resolvedSessionKey,
|
||||
runId,
|
||||
});
|
||||
|
||||
@@ -19,16 +19,19 @@ vi.mock("../session-utils.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/subagent-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/subagent-registry.js")>();
|
||||
vi.mock("../../agents/subagent-registry-read.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/subagent-registry-read.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getLatestSubagentRunByChildSessionKey: (...args: unknown[]) =>
|
||||
getLatestSubagentRunByChildSessionKeyMock(...args),
|
||||
replaceSubagentRunAfterSteer: (...args: unknown[]) => replaceSubagentRunAfterSteerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../session-subagent-reactivation.runtime.js", () => ({
|
||||
replaceSubagentRunAfterSteer: (...args: unknown[]) => replaceSubagentRunAfterSteerMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./chat.js", () => ({
|
||||
chatHandlers: {
|
||||
"chat.send": (...args: unknown[]) => chatSendMock(...args),
|
||||
|
||||
@@ -467,7 +467,7 @@ async function handleSessionSend(params: {
|
||||
});
|
||||
if (sendAcked) {
|
||||
if (shouldAttachPendingMessageSeq({ payload: sendPayload, cached: sendCached })) {
|
||||
reactivateCompletedSubagentSession({
|
||||
await reactivateCompletedSubagentSession({
|
||||
sessionKey: canonicalKey,
|
||||
runId: startedRunId,
|
||||
});
|
||||
|
||||
4
src/gateway/session-archive.runtime.ts
Normal file
4
src/gateway/session-archive.runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
archiveSessionTranscripts,
|
||||
cleanupArchivedSessionTranscripts,
|
||||
} from "./session-archive.fs.js";
|
||||
1
src/gateway/session-subagent-reactivation.runtime.ts
Normal file
1
src/gateway/session-subagent-reactivation.runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { replaceSubagentRunAfterSteer } from "../agents/subagent-registry.js";
|
||||
@@ -3,16 +3,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const getLatestSubagentRunByChildSessionKeyMock = vi.fn();
|
||||
const replaceSubagentRunAfterSteerMock = vi.fn();
|
||||
|
||||
vi.mock("../agents/subagent-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../agents/subagent-registry.js")>();
|
||||
vi.mock("../agents/subagent-registry-read.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../agents/subagent-registry-read.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getLatestSubagentRunByChildSessionKey: (...args: unknown[]) =>
|
||||
getLatestSubagentRunByChildSessionKeyMock(...args),
|
||||
replaceSubagentRunAfterSteer: (...args: unknown[]) => replaceSubagentRunAfterSteerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./session-subagent-reactivation.runtime.js", () => ({
|
||||
replaceSubagentRunAfterSteer: (...args: unknown[]) => replaceSubagentRunAfterSteerMock(...args),
|
||||
}));
|
||||
|
||||
import { reactivateCompletedSubagentSession } from "./session-subagent-reactivation.js";
|
||||
|
||||
describe("reactivateCompletedSubagentSession", () => {
|
||||
@@ -21,7 +24,7 @@ describe("reactivateCompletedSubagentSession", () => {
|
||||
replaceSubagentRunAfterSteerMock.mockReset();
|
||||
});
|
||||
|
||||
it("reactivates the newest ended row even when stale active rows still exist for the same child session", () => {
|
||||
it("reactivates the newest ended row even when stale active rows still exist for the same child session", async () => {
|
||||
const childSessionKey = "agent:main:subagent:followup-race";
|
||||
const latestEndedRun = {
|
||||
runId: "run-current-ended",
|
||||
@@ -39,12 +42,12 @@ describe("reactivateCompletedSubagentSession", () => {
|
||||
getLatestSubagentRunByChildSessionKeyMock.mockReturnValue(latestEndedRun);
|
||||
replaceSubagentRunAfterSteerMock.mockReturnValue(true);
|
||||
|
||||
expect(
|
||||
await expect(
|
||||
reactivateCompletedSubagentSession({
|
||||
sessionKey: childSessionKey,
|
||||
runId: "run-next",
|
||||
}),
|
||||
).toBe(true);
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(getLatestSubagentRunByChildSessionKeyMock).toHaveBeenCalledWith(childSessionKey);
|
||||
expect(replaceSubagentRunAfterSteerMock).toHaveBeenCalledWith({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
getLatestSubagentRunByChildSessionKey,
|
||||
replaceSubagentRunAfterSteer,
|
||||
} from "../agents/subagent-registry.js";
|
||||
import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry-read.js";
|
||||
|
||||
export function reactivateCompletedSubagentSession(params: {
|
||||
async function loadSessionSubagentReactivationRuntime() {
|
||||
return import("./session-subagent-reactivation.runtime.js");
|
||||
}
|
||||
|
||||
export async function reactivateCompletedSubagentSession(params: {
|
||||
sessionKey: string;
|
||||
runId?: string;
|
||||
}): boolean {
|
||||
}): Promise<boolean> {
|
||||
const runId = params.runId?.trim();
|
||||
if (!runId) {
|
||||
return false;
|
||||
@@ -15,6 +16,7 @@ export function reactivateCompletedSubagentSession(params: {
|
||||
if (!existing || typeof existing.endedAt !== "number") {
|
||||
return false;
|
||||
}
|
||||
const { replaceSubagentRunAfterSteer } = await loadSessionSubagentReactivationRuntime();
|
||||
return replaceSubagentRunAfterSteer({
|
||||
previousRunId: existing.runId,
|
||||
nextRunId: runId,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getSubagentSessionStartedAt,
|
||||
listSubagentRunsForController,
|
||||
resolveSubagentSessionStatus,
|
||||
} from "../agents/subagent-registry.js";
|
||||
} from "../agents/subagent-registry-read.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
|
||||
43
src/library.test.ts
Normal file
43
src/library.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("library module imports", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("does not load lazy runtimes on module import", async () => {
|
||||
const replyRuntimeLoads = vi.fn();
|
||||
const promptRuntimeLoads = vi.fn();
|
||||
const binariesRuntimeLoads = vi.fn();
|
||||
const whatsappRuntimeLoads = vi.fn();
|
||||
vi.doMock("./auto-reply/reply.runtime.js", async (importOriginal) => {
|
||||
replyRuntimeLoads();
|
||||
return await importOriginal<typeof import("./auto-reply/reply.runtime.js")>();
|
||||
});
|
||||
vi.doMock("./cli/prompt.js", async (importOriginal) => {
|
||||
promptRuntimeLoads();
|
||||
return await importOriginal<typeof import("./cli/prompt.js")>();
|
||||
});
|
||||
vi.doMock("./infra/binaries.js", async (importOriginal) => {
|
||||
binariesRuntimeLoads();
|
||||
return await importOriginal<typeof import("./infra/binaries.js")>();
|
||||
});
|
||||
vi.doMock("./plugins/runtime/runtime-whatsapp-boundary.js", async (importOriginal) => {
|
||||
whatsappRuntimeLoads();
|
||||
return await importOriginal<
|
||||
typeof import("./plugins/runtime/runtime-whatsapp-boundary.js")
|
||||
>();
|
||||
});
|
||||
|
||||
await import("./library.js");
|
||||
|
||||
expect(replyRuntimeLoads).not.toHaveBeenCalled();
|
||||
expect(promptRuntimeLoads).not.toHaveBeenCalled();
|
||||
expect(binariesRuntimeLoads).not.toHaveBeenCalled();
|
||||
expect(whatsappRuntimeLoads).not.toHaveBeenCalled();
|
||||
vi.doUnmock("./auto-reply/reply.runtime.js");
|
||||
vi.doUnmock("./cli/prompt.js");
|
||||
vi.doUnmock("./infra/binaries.js");
|
||||
vi.doUnmock("./plugins/runtime/runtime-whatsapp-boundary.js");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,85 @@
|
||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
||||
import { applyTemplate } from "./auto-reply/templating.js";
|
||||
import { createDefaultDeps } from "./cli/deps.js";
|
||||
import { promptYesNo } from "./cli/prompt.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { loadConfig } from "./config/config.js";
|
||||
import {
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "./config/sessions.js";
|
||||
import { ensureBinary } from "./infra/binaries.js";
|
||||
import { resolveStorePath } from "./config/sessions/paths.js";
|
||||
import { deriveSessionKey, resolveSessionKey } from "./config/sessions/session-key.js";
|
||||
import { loadSessionStore, saveSessionStore } from "./config/sessions/store.js";
|
||||
import {
|
||||
describePortOwner,
|
||||
ensurePortAvailable,
|
||||
handlePortError,
|
||||
PortInUseError,
|
||||
} from "./infra/ports.js";
|
||||
import { monitorWebChannel } from "./plugins/runtime/runtime-whatsapp-boundary.js";
|
||||
import { runCommandWithTimeout, runExec } from "./process/exec.js";
|
||||
import { assertWebChannel, normalizeE164, toWhatsappJid } from "./utils.js";
|
||||
|
||||
type GetReplyFromConfig = typeof import("./auto-reply/reply.runtime.js").getReplyFromConfig;
|
||||
type PromptYesNo = typeof import("./cli/prompt.js").promptYesNo;
|
||||
type EnsureBinary = typeof import("./infra/binaries.js").ensureBinary;
|
||||
type RunExec = typeof import("./process/exec.js").runExec;
|
||||
type RunCommandWithTimeout = typeof import("./process/exec.js").runCommandWithTimeout;
|
||||
type MonitorWebChannel =
|
||||
typeof import("./plugins/runtime/runtime-whatsapp-boundary.js").monitorWebChannel;
|
||||
|
||||
let replyRuntimePromise: Promise<typeof import("./auto-reply/reply.runtime.js")> | null = null;
|
||||
let promptRuntimePromise: Promise<typeof import("./cli/prompt.js")> | null = null;
|
||||
let binariesRuntimePromise: Promise<typeof import("./infra/binaries.js")> | null = null;
|
||||
let execRuntimePromise: Promise<typeof import("./process/exec.js")> | null = null;
|
||||
let whatsappRuntimePromise: Promise<
|
||||
typeof import("./plugins/runtime/runtime-whatsapp-boundary.js")
|
||||
> | null = null;
|
||||
|
||||
function loadReplyRuntime() {
|
||||
replyRuntimePromise ??= import("./auto-reply/reply.runtime.js");
|
||||
return replyRuntimePromise;
|
||||
}
|
||||
|
||||
function loadPromptRuntime() {
|
||||
promptRuntimePromise ??= import("./cli/prompt.js");
|
||||
return promptRuntimePromise;
|
||||
}
|
||||
|
||||
function loadBinariesRuntime() {
|
||||
binariesRuntimePromise ??= import("./infra/binaries.js");
|
||||
return binariesRuntimePromise;
|
||||
}
|
||||
|
||||
function loadExecRuntime() {
|
||||
execRuntimePromise ??= import("./process/exec.js");
|
||||
return execRuntimePromise;
|
||||
}
|
||||
|
||||
function loadWhatsAppRuntime() {
|
||||
whatsappRuntimePromise ??= import("./plugins/runtime/runtime-whatsapp-boundary.js");
|
||||
return whatsappRuntimePromise;
|
||||
}
|
||||
|
||||
export const getReplyFromConfig: GetReplyFromConfig = async (...args) =>
|
||||
(await loadReplyRuntime()).getReplyFromConfig(...args);
|
||||
export const promptYesNo: PromptYesNo = async (...args) =>
|
||||
(await loadPromptRuntime()).promptYesNo(...args);
|
||||
export const ensureBinary: EnsureBinary = async (...args) =>
|
||||
(await loadBinariesRuntime()).ensureBinary(...args);
|
||||
export const runExec: RunExec = async (...args) => (await loadExecRuntime()).runExec(...args);
|
||||
export const runCommandWithTimeout: RunCommandWithTimeout = async (...args) =>
|
||||
(await loadExecRuntime()).runCommandWithTimeout(...args);
|
||||
export const monitorWebChannel: MonitorWebChannel = async (...args) =>
|
||||
(await loadWhatsAppRuntime()).monitorWebChannel(...args);
|
||||
|
||||
export {
|
||||
assertWebChannel,
|
||||
applyTemplate,
|
||||
createDefaultDeps,
|
||||
deriveSessionKey,
|
||||
describePortOwner,
|
||||
ensureBinary,
|
||||
ensurePortAvailable,
|
||||
getReplyFromConfig,
|
||||
handlePortError,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
monitorWebChannel,
|
||||
normalizeE164,
|
||||
PortInUseError,
|
||||
promptYesNo,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
runCommandWithTimeout,
|
||||
runExec,
|
||||
saveSessionStore,
|
||||
toWhatsappJid,
|
||||
waitForever,
|
||||
|
||||
Reference in New Issue
Block a user