mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 04:01:42 +00:00
* refactor(config): consolidate media model lists * refactor(config): unify memory configuration * refactor(config): consolidate TTS ownership * refactor(config): move typing policy to agents * refactor(config): retire product-level config surfaces * refactor(config): share scoped tool policy type * chore(config): refresh generated baselines * fix(config): honor agent typing overrides * fix(config): migrate sibling config consumers * refactor(infra): keep base64url decoder private * fix(config): strip invalid legacy TTS values * chore(config): refresh rebased baseline hash * fix(doctor): route legacy messages.tts.realtime voice to talk during tts move * refactor(config): polish final layout names * refactor(config): freeze retired tuning defaults * feat(config): add fast mode default symmetry * refactor(config): key agent entries by id * docs(config): update final layout reference * test(config): cover final layout migrations * chore(config): refresh final layout baselines * fix(config): align final layout runtime readers * fix(config): align remaining readers * fix(config): stabilize final layout migrations * fix(config): finalize config projection proof * fix(config): address final layout review * docs(release): preserve historical config names * fix(config): complete keyed agent migration * fix(config): close final migration gaps * fix(config): finish full-branch review * fix(config): complete runtime secret detection * fix(config): close final review findings * fix(config): finish canonical docs and heartbeat migration * fix(config): integrate latest main after rebase * refactor(env): isolate test-only controls * refactor(env): isolate build and development controls * refactor(env): collapse process identity indirection * refactor(env): remove duplicate config and temp aliases * docs(env): define the operator-facing allowlist * ci(env): ratchet production variable count * fix(env): remove stale provider helper import * fix(env): make ratchet sorting explicit * test(env): keep test seam in dead-code audit * test(env): cover ratchet growth and boundary; document surface budgets * docs(config): document tier-eval consolidations * docs(config): clarify speech preference ownership * test(memory): align retired tuning fixtures * refactor(memory): freeze engine heuristics * refactor(config): apply tier-eval tranche * refactor(tts): move persona shaping to providers * refactor(compaction): move prompt policy to providers * test(config): align hookified prompt fixtures * chore(deadcode): classify test-only exports * chore(github): remove unused spawn helper * chore(deadcode): classify queue diagnostics * chore(deadcode): remove unused lane snapshot export * chore(plugin-sdk): ratchet consolidated surface * fix(config): integrate latest main after rebase
397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
// Covers canonical commitment persistence, queries, and concurrent mutations.
|
|
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 { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
|
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
|
|
import {
|
|
listCommitments,
|
|
listDueCommitmentSessionKeys,
|
|
listDueCommitmentsForSession,
|
|
listPendingCommitmentsForScope,
|
|
markCommitmentsAttempted,
|
|
markCommitmentsStatus,
|
|
upsertInferredCommitments,
|
|
} from "./store.js";
|
|
import { readCommitmentsForTest, seedCommitmentsForTest } from "./store.test-utils.js";
|
|
import type { CommitmentCandidate, CommitmentRecord } from "./types.js";
|
|
|
|
const resolveCommitmentsConfigMock = vi.hoisted(() => vi.fn());
|
|
const enabledConfig = (maxPerDay = 3) => ({
|
|
enabled: true,
|
|
maxPerDay,
|
|
extraction: {
|
|
debounceMs: 15_000,
|
|
batchMaxItems: 8,
|
|
queueMaxItems: 64,
|
|
confidenceThreshold: 0.72,
|
|
careConfidenceThreshold: 0.86,
|
|
timeoutSeconds: 45,
|
|
},
|
|
});
|
|
|
|
vi.mock("./config.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("./config.js")>()),
|
|
resolveCommitmentsConfig: resolveCommitmentsConfigMock,
|
|
}));
|
|
|
|
describe("commitment SQLite store", () => {
|
|
const tmpDirs: string[] = [];
|
|
let stateDirEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
|
|
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
|
|
const sessionKey = "agent:main:telegram:user-155462274";
|
|
|
|
beforeEach(() => {
|
|
resolveCommitmentsConfigMock.mockReset();
|
|
resolveCommitmentsConfigMock.mockReturnValue(enabledConfig());
|
|
});
|
|
|
|
afterEach(async () => {
|
|
closeOpenClawStateDatabaseForTest();
|
|
vi.unstubAllEnvs();
|
|
stateDirEnvSnapshot?.restore();
|
|
stateDirEnvSnapshot = undefined;
|
|
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
tmpDirs.length = 0;
|
|
});
|
|
|
|
async function useTempStateDir(): Promise<string> {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitments-store-"));
|
|
tmpDirs.push(tmpDir);
|
|
stateDirEnvSnapshot ??= captureEnv(["OPENCLAW_STATE_DIR"]);
|
|
setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir);
|
|
return tmpDir;
|
|
}
|
|
|
|
function commitment(overrides?: Partial<CommitmentRecord>): CommitmentRecord {
|
|
return {
|
|
id: "cm_interview",
|
|
agentId: "main",
|
|
sessionKey,
|
|
channel: "telegram",
|
|
to: "155462274",
|
|
kind: "event_check_in",
|
|
sensitivity: "routine",
|
|
source: "inferred_user_context",
|
|
status: "pending",
|
|
reason: "The user said they had an interview yesterday.",
|
|
suggestedText: "How did the interview go?",
|
|
dedupeKey: "interview:2026-04-28",
|
|
confidence: 0.92,
|
|
dueWindow: {
|
|
earliestMs: nowMs - 60_000,
|
|
latestMs: nowMs + 60 * 60_000,
|
|
timezone: "America/Los_Angeles",
|
|
},
|
|
createdAtMs: nowMs - 24 * 60 * 60_000,
|
|
updatedAtMs: nowMs - 24 * 60 * 60_000,
|
|
attempts: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("does not surface due commitments unless inferred commitments are enabled", async () => {
|
|
resolveCommitmentsConfigMock.mockReturnValue({ ...enabledConfig(), enabled: false });
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([commitment()]);
|
|
|
|
await expect(
|
|
listDueCommitmentsForSession({ cfg: {}, agentId: "main", sessionKey, nowMs }),
|
|
).resolves.toStrictEqual([]);
|
|
});
|
|
|
|
it("limits delivered commitments per agent session in a rolling day", async () => {
|
|
resolveCommitmentsConfigMock.mockReturnValue(enabledConfig(1));
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({ id: "cm_sent", status: "sent", sentAtMs: nowMs - 60_000 }),
|
|
commitment({ id: "cm_pending", dedupeKey: "interview:followup" }),
|
|
]);
|
|
|
|
await expect(
|
|
listDueCommitmentsForSession({
|
|
cfg: {},
|
|
agentId: "main",
|
|
sessionKey,
|
|
nowMs,
|
|
}),
|
|
).resolves.toStrictEqual([]);
|
|
expect(readCommitmentsForTest()).toHaveLength(2);
|
|
});
|
|
|
|
it("preserves due windows, snoozes, caps, agent scope, and key ordering", async () => {
|
|
resolveCommitmentsConfigMock.mockReturnValue(enabledConfig(2));
|
|
await useTempStateDir();
|
|
const sessionA = "agent:main:telegram:user-a";
|
|
const sessionB = "agent:main:telegram:user-b";
|
|
const sessionC = "agent:main:telegram:user-c";
|
|
seedCommitmentsForTest([
|
|
commitment({ id: "cm_b_sent_1", sessionKey: sessionB, status: "sent", sentAtMs: nowMs }),
|
|
commitment({
|
|
id: "cm_b_sent_2",
|
|
sessionKey: sessionB,
|
|
status: "sent",
|
|
sentAtMs: nowMs - 60_000,
|
|
}),
|
|
commitment({ id: "cm_b_due", sessionKey: sessionB }),
|
|
commitment({ id: "cm_c_due", sessionKey: sessionC }),
|
|
commitment({ id: "cm_a_due", sessionKey: sessionA }),
|
|
commitment({
|
|
id: "cm_old_sent",
|
|
sessionKey: sessionA,
|
|
status: "sent",
|
|
sentAtMs: nowMs - 25 * 60 * 60_000,
|
|
}),
|
|
commitment({
|
|
id: "cm_c_snoozed",
|
|
sessionKey: sessionC,
|
|
status: "snoozed",
|
|
snoozedUntilMs: nowMs + 60_000,
|
|
}),
|
|
commitment({
|
|
id: "cm_c_future",
|
|
sessionKey: sessionC,
|
|
dueWindow: {
|
|
earliestMs: nowMs + 60_000,
|
|
latestMs: nowMs + 120_000,
|
|
timezone: "America/Los_Angeles",
|
|
},
|
|
}),
|
|
commitment({ id: "cm_other_agent", agentId: "work", sessionKey: sessionA }),
|
|
]);
|
|
|
|
await expect(
|
|
listDueCommitmentSessionKeys({
|
|
cfg: {},
|
|
agentId: "main",
|
|
nowMs,
|
|
}),
|
|
).resolves.toStrictEqual([sessionA, sessionC]);
|
|
});
|
|
|
|
it("discovers one concentrated due session through indexed SQLite queries", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({ id: "cm_sent", status: "sent", sentAtMs: nowMs - 60_000 }),
|
|
...Array.from({ length: 8_000 }, (_, index) =>
|
|
commitment({ id: `cm_due_${index}`, dedupeKey: `interview:followup:${index}` }),
|
|
),
|
|
]);
|
|
|
|
await expect(
|
|
listDueCommitmentSessionKeys({
|
|
cfg: {},
|
|
agentId: "main",
|
|
nowMs,
|
|
limit: 10,
|
|
}),
|
|
).resolves.toStrictEqual([sessionKey]);
|
|
});
|
|
|
|
it("expires stale pending commitments atomically", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({
|
|
dueWindow: {
|
|
earliestMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
latestMs: nowMs - 4 * 24 * 60 * 60_000,
|
|
timezone: "America/Los_Angeles",
|
|
},
|
|
}),
|
|
]);
|
|
|
|
await expect(
|
|
listDueCommitmentsForSession({
|
|
cfg: {},
|
|
agentId: "main",
|
|
sessionKey,
|
|
nowMs,
|
|
}),
|
|
).resolves.toStrictEqual([]);
|
|
expect(readCommitmentsForTest()[0]).toMatchObject({
|
|
id: "cm_interview",
|
|
status: "expired",
|
|
expiredAtMs: nowMs,
|
|
updatedAtMs: nowMs,
|
|
});
|
|
});
|
|
|
|
it("matches the complete route scope", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({ accountId: "primary", threadId: "topic", senderId: "sender" }),
|
|
]);
|
|
await expect(
|
|
listPendingCommitmentsForScope({
|
|
scope: {
|
|
agentId: "main",
|
|
sessionKey,
|
|
channel: "telegram",
|
|
accountId: "primary",
|
|
to: "155462274",
|
|
threadId: "topic",
|
|
senderId: "sender",
|
|
},
|
|
nowMs,
|
|
}),
|
|
).resolves.toHaveLength(1);
|
|
await expect(
|
|
listPendingCommitmentsForScope({
|
|
scope: {
|
|
agentId: "main",
|
|
sessionKey,
|
|
channel: "telegram",
|
|
accountId: "other",
|
|
to: "155462274",
|
|
threadId: "topic",
|
|
senderId: "sender",
|
|
},
|
|
nowMs,
|
|
}),
|
|
).resolves.toStrictEqual([]);
|
|
});
|
|
|
|
it("lists expired commitments after expiry transition", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({
|
|
dueWindow: {
|
|
earliestMs: nowMs - 5 * 24 * 60 * 60_000,
|
|
latestMs: nowMs - 4 * 24 * 60 * 60_000,
|
|
timezone: "America/Los_Angeles",
|
|
},
|
|
}),
|
|
]);
|
|
await listDueCommitmentsForSession({
|
|
cfg: {},
|
|
agentId: "main",
|
|
sessionKey,
|
|
nowMs,
|
|
});
|
|
await expect(listCommitments({ status: "expired", nowMs })).resolves.toMatchObject([
|
|
{ id: "cm_interview", status: "expired" },
|
|
]);
|
|
});
|
|
|
|
it("preserves concurrent status writes to disjoint ids", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({ id: "cm_raceA", dedupeKey: "race-A" }),
|
|
commitment({ id: "cm_raceB", dedupeKey: "race-B" }),
|
|
]);
|
|
await Promise.all([
|
|
markCommitmentsStatus({ ids: ["cm_raceA"], status: "dismissed", nowMs }),
|
|
markCommitmentsStatus({ ids: ["cm_raceB"], status: "dismissed", nowMs }),
|
|
]);
|
|
const byId = Object.fromEntries(readCommitmentsForTest().map((record) => [record.id, record]));
|
|
expect(byId.cm_raceA?.status).toBe("dismissed");
|
|
expect(byId.cm_raceB?.status).toBe("dismissed");
|
|
});
|
|
|
|
it("increments concurrent attempt bumps without losing a write", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([commitment({ id: "cm_race_attempts", attempts: 0 })]);
|
|
await Promise.all(
|
|
Array.from({ length: 5 }, () =>
|
|
markCommitmentsAttempted({ ids: ["cm_race_attempts"], nowMs }),
|
|
),
|
|
);
|
|
expect(readCommitmentsForTest()[0]?.attempts).toBe(5);
|
|
});
|
|
|
|
it("serializes a terminal transition against an unrelated attempt bump", async () => {
|
|
await useTempStateDir();
|
|
seedCommitmentsForTest([
|
|
commitment({ id: "cm_dismiss_target", dedupeKey: "dismiss-target" }),
|
|
commitment({ id: "cm_attempt_target", dedupeKey: "attempt-target", attempts: 2 }),
|
|
]);
|
|
await Promise.all([
|
|
markCommitmentsStatus({ ids: ["cm_dismiss_target"], status: "dismissed", nowMs }),
|
|
markCommitmentsAttempted({ ids: ["cm_attempt_target"], nowMs }),
|
|
]);
|
|
const byId = Object.fromEntries(readCommitmentsForTest().map((record) => [record.id, record]));
|
|
expect(byId.cm_dismiss_target?.status).toBe("dismissed");
|
|
expect(byId.cm_attempt_target?.attempts).toBe(3);
|
|
});
|
|
|
|
it("deduplicates concurrent same-scope inference inside SQLite transactions", async () => {
|
|
await useTempStateDir();
|
|
const candidate: CommitmentCandidate = {
|
|
itemId: "item",
|
|
kind: "event_check_in",
|
|
sensitivity: "routine",
|
|
source: "inferred_user_context",
|
|
reason: "Interview",
|
|
suggestedText: "How did it go?",
|
|
dedupeKey: "interview:2026-04-28",
|
|
confidence: 0.9,
|
|
dueWindow: { earliest: new Date(nowMs).toISOString() },
|
|
};
|
|
const params = {
|
|
item: {
|
|
agentId: "main",
|
|
sessionKey,
|
|
channel: "telegram",
|
|
to: "155462274",
|
|
itemId: "item",
|
|
nowMs,
|
|
timezone: "UTC",
|
|
userText: "Interview tomorrow",
|
|
existingPending: [],
|
|
},
|
|
candidates: [{ candidate, earliestMs: nowMs, latestMs: nowMs + 60_000, timezone: "UTC" }],
|
|
nowMs,
|
|
};
|
|
await Promise.all([upsertInferredCommitments(params), upsertInferredCommitments(params)]);
|
|
expect(readCommitmentsForTest()).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects malformed candidates before they can poison canonical rows", async () => {
|
|
await useTempStateDir();
|
|
const candidate: CommitmentCandidate = {
|
|
itemId: "item",
|
|
kind: "event_check_in",
|
|
sensitivity: "routine",
|
|
source: "inferred_user_context",
|
|
reason: " ",
|
|
suggestedText: "How did it go?",
|
|
dedupeKey: "interview:invalid",
|
|
confidence: 0.9,
|
|
dueWindow: { earliest: new Date(nowMs).toISOString() },
|
|
};
|
|
const created = await upsertInferredCommitments({
|
|
item: {
|
|
agentId: "main",
|
|
sessionKey,
|
|
channel: "telegram",
|
|
to: "155462274",
|
|
itemId: "item",
|
|
nowMs,
|
|
timezone: "UTC",
|
|
userText: "Interview tomorrow",
|
|
existingPending: [],
|
|
},
|
|
candidates: [{ candidate, earliestMs: nowMs, latestMs: nowMs + 60_000, timezone: "UTC" }],
|
|
nowMs,
|
|
});
|
|
|
|
expect(created).toStrictEqual([]);
|
|
await expect(listCommitments({ nowMs })).resolves.toStrictEqual([]);
|
|
});
|
|
|
|
it("never creates the retired commitments JSON directory", async () => {
|
|
const stateDir = await useTempStateDir();
|
|
seedCommitmentsForTest([commitment()]);
|
|
await listDueCommitmentsForSession({
|
|
cfg: {},
|
|
agentId: "main",
|
|
sessionKey,
|
|
nowMs,
|
|
});
|
|
await expect(fs.stat(path.join(stateDir, "commitments"))).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
});
|
|
});
|