mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
0a8e3604ba
* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
284 lines
9.3 KiB
TypeScript
284 lines
9.3 KiB
TypeScript
// Subagent delivery-context tests protect route metadata inheritance for child
|
|
// agent sessions and outbound delivery through channel plugins.
|
|
import { describe, expect, test } from "vitest";
|
|
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
|
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
|
|
import {
|
|
createChannelTestPluginBase,
|
|
createDirectOutboundTestAdapter,
|
|
} from "../test-utils/channel-plugins.js";
|
|
import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js";
|
|
import { createRegistry } from "./server.e2e-registry-helpers.js";
|
|
import { installConnectedSessionStoreGatewaySuite } from "./test-helpers.connected-session-store.js";
|
|
import { installGatewayTestHooks, rpcReq, testState, writeSessionStore } from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
const gatewaySuite = installConnectedSessionStoreGatewaySuite("openclaw-gw-subagent-delivery-ctx-");
|
|
|
|
const createStubChannelPlugin = (params: {
|
|
id: ChannelPlugin["id"];
|
|
label: string;
|
|
}): ChannelPlugin => ({
|
|
...createChannelTestPluginBase({
|
|
id: params.id,
|
|
label: params.label,
|
|
}),
|
|
outbound: createDirectOutboundTestAdapter({
|
|
channel: params.id,
|
|
resolveTarget: ({ to }) => {
|
|
const trimmed = to?.trim() ?? "";
|
|
if (trimmed) {
|
|
return { ok: true, to: trimmed };
|
|
}
|
|
return { ok: false, error: new Error(`missing target for ${params.id}`) };
|
|
},
|
|
}),
|
|
});
|
|
|
|
const defaultRegistry = createRegistry([
|
|
{
|
|
pluginId: "slack",
|
|
source: "test",
|
|
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
|
|
},
|
|
]);
|
|
|
|
type StoredEntry = {
|
|
route?: {
|
|
channel?: string;
|
|
accountId?: string;
|
|
target?: { to?: string; rawTo?: string; chatType?: string };
|
|
thread?: { id?: string | number; kind?: string; source?: string };
|
|
};
|
|
deliveryContext?: { channel?: string; to?: string; threadId?: string; accountId?: string };
|
|
lastChannel?: string;
|
|
lastTo?: string;
|
|
lastThreadId?: string | number;
|
|
lastAccountId?: string;
|
|
};
|
|
|
|
type StoreEntries = Parameters<typeof writeSessionStore>[0]["entries"];
|
|
|
|
async function prepareSessionStore(entries: StoreEntries = {}): Promise<void> {
|
|
setRegistry(defaultRegistry);
|
|
testState.sessionStorePath = gatewaySuite.sessionStorePath;
|
|
await writeSessionStore({ entries });
|
|
}
|
|
|
|
function readDeliveryContext(entry: StoredEntry): NonNullable<StoredEntry["deliveryContext"]> {
|
|
if (!entry.deliveryContext) {
|
|
throw new Error("expected stored deliveryContext");
|
|
}
|
|
return entry.deliveryContext;
|
|
}
|
|
|
|
async function readStoredSessionEntry(key: string): Promise<StoredEntry> {
|
|
const entry = loadSessionEntry({ sessionKey: key, storePath: gatewaySuite.sessionStorePath }) as
|
|
| StoredEntry
|
|
| undefined;
|
|
if (!entry) {
|
|
throw new Error(`expected stored entry ${key}`);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
async function sendAgentRequest(params: Record<string, unknown>): Promise<void> {
|
|
const res = await rpcReq(gatewaySuite.ws, "agent", {
|
|
deliver: false,
|
|
...params,
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
}
|
|
|
|
function expectDeliveryContextFields(entry: StoredEntry, expected: Record<string, unknown>): void {
|
|
const deliveryContext = readDeliveryContext(entry);
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
expect(deliveryContext[key as keyof typeof deliveryContext]).toBe(value);
|
|
}
|
|
}
|
|
|
|
describe("subagent session deliveryContext from spawn request params", () => {
|
|
test("new subagent session inherits deliveryContext from request channel/to/threadId", async () => {
|
|
await prepareSessionStore();
|
|
|
|
await sendAgentRequest({
|
|
message: "[Subagent Task]: analyze data",
|
|
sessionKey: "agent:main:subagent:test-delivery-ctx",
|
|
channel: "slack",
|
|
to: "channel:C0AF8TW48UQ",
|
|
accountId: "default",
|
|
threadId: "1774374945.091819",
|
|
idempotencyKey: "idem-subagent-delivery-ctx-1",
|
|
});
|
|
|
|
const entry = await readStoredSessionEntry("agent:main:subagent:test-delivery-ctx");
|
|
expectDeliveryContextFields(entry, {
|
|
channel: "slack",
|
|
to: "channel:C0AF8TW48UQ",
|
|
threadId: "1774374945.091819",
|
|
accountId: "default",
|
|
});
|
|
expect(entry.route).toEqual({
|
|
channel: "slack",
|
|
accountId: "default",
|
|
target: { to: "channel:C0AF8TW48UQ" },
|
|
thread: { id: "1774374945.091819" },
|
|
});
|
|
expect(entry.lastChannel).toBe("slack");
|
|
expect(entry.lastTo).toBe("channel:C0AF8TW48UQ");
|
|
});
|
|
|
|
test("existing session deliveryContext is NOT overwritten by request params", async () => {
|
|
await prepareSessionStore({
|
|
"agent:main:subagent:existing-ctx": {
|
|
sessionId: "sess-existing",
|
|
updatedAt: Date.now(),
|
|
deliveryContext: {
|
|
channel: "slack",
|
|
to: "user:U09U1LV7JDN",
|
|
accountId: "default",
|
|
threadId: "1771242986.529939",
|
|
},
|
|
lastChannel: "slack",
|
|
lastTo: "user:U09U1LV7JDN",
|
|
lastAccountId: "default",
|
|
lastThreadId: "1771242986.529939",
|
|
},
|
|
});
|
|
|
|
await sendAgentRequest({
|
|
message: "follow-up",
|
|
sessionKey: "agent:main:subagent:existing-ctx",
|
|
channel: "slack",
|
|
to: "channel:C0AF8TW48UQ",
|
|
threadId: "9999999999.000000",
|
|
idempotencyKey: "idem-subagent-delivery-ctx-2",
|
|
});
|
|
|
|
const entry = await readStoredSessionEntry("agent:main:subagent:existing-ctx");
|
|
// The ORIGINAL deliveryContext should be preserved (primary wins in merge).
|
|
expectDeliveryContextFields(entry, {
|
|
to: "user:U09U1LV7JDN",
|
|
threadId: "1771242986.529939",
|
|
});
|
|
expect(entry.lastTo).toBe("user:U09U1LV7JDN");
|
|
});
|
|
|
|
test("existing session route metadata survives agent request delivery normalization", async () => {
|
|
await prepareSessionStore({
|
|
"agent:main:subagent:existing-route-metadata": {
|
|
sessionId: "sess-existing-route",
|
|
updatedAt: Date.now(),
|
|
route: {
|
|
channel: "slack",
|
|
accountId: "default",
|
|
target: {
|
|
to: "channel:C0AF8TW48UQ",
|
|
rawTo: "slack://C0AF8TW48UQ",
|
|
chatType: "channel",
|
|
},
|
|
thread: {
|
|
id: "1771242986.529939",
|
|
kind: "thread",
|
|
source: "target",
|
|
},
|
|
},
|
|
deliveryContext: {
|
|
channel: "slack",
|
|
to: "channel:C0AF8TW48UQ",
|
|
accountId: "default",
|
|
threadId: "1771242986.529939",
|
|
},
|
|
lastChannel: "slack",
|
|
lastTo: "channel:C0AF8TW48UQ",
|
|
lastAccountId: "default",
|
|
lastThreadId: "1771242986.529939",
|
|
},
|
|
});
|
|
|
|
await sendAgentRequest({
|
|
message: "follow-up",
|
|
sessionKey: "agent:main:subagent:existing-route-metadata",
|
|
channel: "slack",
|
|
to: "channel:C0AF8TW48UQ",
|
|
accountId: "default",
|
|
threadId: "1771242986.529939",
|
|
idempotencyKey: "idem-subagent-delivery-route-metadata",
|
|
});
|
|
|
|
const entry = await readStoredSessionEntry("agent:main:subagent:existing-route-metadata");
|
|
expect(entry.route).toEqual({
|
|
channel: "slack",
|
|
accountId: "default",
|
|
target: {
|
|
to: "channel:C0AF8TW48UQ",
|
|
rawTo: "slack://C0AF8TW48UQ",
|
|
chatType: "channel",
|
|
},
|
|
thread: {
|
|
id: "1771242986.529939",
|
|
kind: "thread",
|
|
source: "target",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("pre-patched subagent session (via sessions.patch) inherits deliveryContext from agent request", async () => {
|
|
// Simulates the real subagent spawn flow: spawnSubagentDirect calls sessions.patch
|
|
// first (to set spawnDepth, spawnedBy, etc.), then calls callSubagentGateway({method: "agent"}).
|
|
// The sessions.patch creates a partial entry without deliveryContext.
|
|
// The agent handler must seed deliveryContext from the request params.
|
|
await prepareSessionStore({
|
|
"agent:main:subagent:pre-patched": {
|
|
sessionId: "sess-pre-patched",
|
|
updatedAt: Date.now(),
|
|
spawnDepth: 1,
|
|
spawnedBy: "agent:main:slack:direct:u07fdr83w6n:thread:1775577152.364109",
|
|
},
|
|
});
|
|
|
|
await sendAgentRequest({
|
|
message: "[Subagent Task]: investigate data",
|
|
sessionKey: "agent:main:subagent:pre-patched",
|
|
channel: "slack",
|
|
to: "user:U07FDR83W6N",
|
|
accountId: "default",
|
|
threadId: "1775577152.364109",
|
|
idempotencyKey: "idem-subagent-delivery-ctx-prepatched",
|
|
});
|
|
|
|
const entry = await readStoredSessionEntry("agent:main:subagent:pre-patched");
|
|
expectDeliveryContextFields(entry, {
|
|
channel: "slack",
|
|
to: "user:U07FDR83W6N",
|
|
threadId: "1775577152.364109",
|
|
accountId: "default",
|
|
});
|
|
expect(entry.route).toEqual({
|
|
channel: "slack",
|
|
accountId: "default",
|
|
target: { to: "user:U07FDR83W6N" },
|
|
thread: { id: "1775577152.364109" },
|
|
});
|
|
expect(entry.lastThreadId).toBe("1775577152.364109");
|
|
});
|
|
|
|
test("request without to/threadId does not inject empty values", async () => {
|
|
await prepareSessionStore();
|
|
|
|
await sendAgentRequest({
|
|
message: "internal task",
|
|
sessionKey: "agent:main:subagent:no-routing",
|
|
channel: "slack",
|
|
idempotencyKey: "idem-subagent-delivery-ctx-3",
|
|
});
|
|
|
|
const entry = await readStoredSessionEntry("agent:main:subagent:no-routing");
|
|
expectDeliveryContextFields(entry, { channel: "slack" });
|
|
const deliveryContext = readDeliveryContext(entry);
|
|
expect(deliveryContext.to).toBeUndefined();
|
|
expect(deliveryContext.threadId).toBeUndefined();
|
|
});
|
|
});
|