openclaw / src /agents /subagents /spawn /subagent-depth.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4c76b0d verified
Raw History Blame Contribute Delete
8.77 kB
// Subagent depth tests cover depth recovery from persisted session metadata and
// timer-safe timeout normalization for spawned agent runs.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { replaceSessionEntry } from "../../../config/sessions/session-accessor.js";
import { resolveAgentTimeoutMs } from "../../timeout.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
describe("getSubagentDepthFromSessionStore", () => {
it("uses spawnDepth from the session store when available", () => {
const key = "agent:main:subagent:flat";
const depth = getSubagentDepthFromSessionStore(key, {
store: {
[key]: { spawnDepth: 2 },
},
});
expect(depth).toBe(2);
});
it("normalizes signed decimal stored spawnDepth strings", () => {
const key = "agent:main:subagent:flat";
const depth = getSubagentDepthFromSessionStore(key, {
store: {
[key]: { spawnDepth: "+02" },
},
});
expect(depth).toBe(2);
});
it("ignores non-decimal and unsafe stored spawnDepth strings", () => {
const key = "agent:main:subagent:flat";
for (const spawnDepth of ["1e3", "0x10", "1.5", "9007199254740993"]) {
const depth = getSubagentDepthFromSessionStore(key, {
store: {
[key]: { spawnDepth },
},
});
expect(depth).toBe(1);
}
});
it("derives depth from spawnedBy ancestry when spawnDepth is missing", () => {
// Ancestry fallback keeps restored sessions useful when old stores predate
// the explicit spawnDepth field.
const key1 = "agent:main:subagent:one";
const key2 = "agent:main:subagent:two";
const key3 = "agent:main:subagent:three";
const depth = getSubagentDepthFromSessionStore(key3, {
store: {
[key1]: { spawnedBy: "agent:main:main" },
[key2]: { spawnedBy: key1 },
[key3]: { spawnedBy: key2 },
},
});
expect(depth).toBe(3);
});
it("ignores parentSessionKey threading when resolving spawn depth", () => {
// parentSessionKey is UI threading (dashboard auto-parenting, forks); only
// stored spawnDepth and spawnedBy lineage may contribute depth.
const store = {
"agent:main:main": { sessionId: "root" },
"agent:main:dashboard:operator": {
sessionId: "operator",
spawnDepth: 0,
parentSessionKey: "agent:main:main",
},
"agent:main:dashboard:legacy": {
sessionId: "legacy",
parentSessionKey: "agent:main:main",
},
};
expect(getSubagentDepthFromSessionStore("agent:main:dashboard:operator", { store })).toBe(0);
expect(getSubagentDepthFromSessionStore("agent:main:dashboard:legacy", { store })).toBe(0);
});
it("resolves depth when caller is identified by sessionId", () => {
const key1 = "agent:main:subagent:one";
const key2 = "agent:main:subagent:two";
const key3 = "agent:main:subagent:three";
const depth = getSubagentDepthFromSessionStore("subagent-three-session", {
store: {
[key1]: { sessionId: "subagent-one-session", spawnedBy: "agent:main:main" },
[key2]: { sessionId: "subagent-two-session", spawnedBy: key1 },
[key3]: { sessionId: "subagent-three-session", spawnedBy: key2 },
},
});
expect(depth).toBe(3);
});
it("resolves prefixed store keys when caller key omits the agent prefix", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-"));
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const prefixedKey = "agent:main:subagent:flat";
const storePath = storeTemplate.replaceAll("{agentId}", "main");
await replaceSessionEntry(
{
storePath,
sessionKey: prefixedKey,
},
{
sessionId: "subagent-flat",
updatedAt: Date.now(),
spawnDepth: 2,
},
);
const depth = getSubagentDepthFromSessionStore("subagent:flat", {
cfg: {
session: {
store: storeTemplate,
},
},
});
expect(depth).toBe(2);
});
it("reads a bare fixed-store key through its persisted owner", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-shared-"));
try {
const storePath = path.join(tmpDir, "sessions.sqlite");
await replaceSessionEntry(
{ agentId: "ops", storePath, sessionKey: "global" },
{
sessionId: "global-session",
updatedAt: Date.now(),
spawnDepth: 2,
},
);
const cfg = {
agents: {
ownership: "explicit",
defaults: { sessionStore: { agentId: "ops" } },
entries: { ops: {}, research: {} },
},
session: { scope: "global", store: storePath },
} satisfies OpenClawConfig;
expect(getSubagentDepthFromSessionStore("global", { cfg })).toBe(2);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("resolves a cross-agent parent outside the supplied child store", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-cross-agent-"));
try {
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const parentKey = "agent:main:dashboard:parent";
await replaceSessionEntry(
{
agentId: "main",
storePath: storeTemplate.replaceAll("{agentId}", "main"),
sessionKey: parentKey,
},
{
sessionId: "parent",
updatedAt: Date.now(),
spawnDepth: 2,
},
);
const depth = getSubagentDepthFromSessionStore("agent:work:dashboard:child", {
cfg: { session: { store: storeTemplate } },
store: {
"agent:work:dashboard:child": {
sessionId: "child",
spawnedBy: parentKey,
},
},
});
expect(depth).toBe(3);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("keeps agent-scoped views separate for a fixed shared store", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-fixed-"));
try {
const storePath = path.join(tmpDir, "sessions.sqlite");
const childKey = "agent:ops:dashboard:child";
const parentKey = "agent:research:dashboard:parent";
await replaceSessionEntry(
{ agentId: "ops", storePath, sessionKey: childKey },
{
sessionId: "child",
updatedAt: Date.now(),
spawnedBy: parentKey,
},
);
await replaceSessionEntry(
{ agentId: "research", storePath, sessionKey: parentKey },
{
sessionId: "parent",
updatedAt: Date.now(),
spawnDepth: 2,
},
);
expect(
getSubagentDepthFromSessionStore(childKey, {
cfg: {
agents: { ownership: "explicit", entries: { ops: {}, research: {} } },
session: { store: storePath },
},
}),
).toBe(3);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("falls back to session-key segment counting when metadata is missing", () => {
const key = "agent:main:subagent:flat";
const depth = getSubagentDepthFromSessionStore(key, {
store: {
[key]: {},
},
});
expect(depth).toBe(1);
});
});
describe("resolveAgentTimeoutMs", () => {
it("defaults to 48 hours when config does not override the timeout", () => {
expect(resolveAgentTimeoutMs({})).toBe(48 * 60 * 60 * 1000);
});
it.each([
["unlimited", 0, MAX_TIMER_TIMEOUT_MS],
["finite", 30, 30_000],
["negative", -1, 1_000],
["NaN", Number.NaN, 48 * 60 * 60 * 1000],
])("resolves config timeoutSeconds %s", (_label, timeoutSeconds, expected) => {
const cfg = { agents: { defaults: { timeoutSeconds } } } as OpenClawConfig;
expect(resolveAgentTimeoutMs({ cfg })).toBe(expected);
});
it("uses a timer-safe sentinel for no-timeout overrides", () => {
expect(resolveAgentTimeoutMs({ overrideSeconds: 0 })).toBe(MAX_TIMER_TIMEOUT_MS);
expect(resolveAgentTimeoutMs({ overrideMs: 0 })).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("clamps very large timeout overrides to timer-safe values", () => {
expect(resolveAgentTimeoutMs({ overrideSeconds: 9_999_999 })).toBe(MAX_TIMER_TIMEOUT_MS);
expect(resolveAgentTimeoutMs({ overrideMs: 9_999_999_999 })).toBe(MAX_TIMER_TIMEOUT_MS);
});
});