File size: 8,765 Bytes
4c76b0d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
// 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);
  });
});