Files
openclaw/src/gateway/server.agent.subagent-delivery-context.test.ts
T
Josh Lehman 0a8e3604ba refactor: flip sessions and transcripts to sqlite storage (#98236)
* 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>
2026-07-11 14:50:37 -07:00

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();
});
});