From ae0baa4a3713e9feab516f80e0779d049ab5b03e Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Mon, 31 Aug 2026 08:58:14 -0600 Subject: [PATCH] feat: expose parent session identity to child extension contexts RLM children stamp their own provider session identity, but nothing extension-visible names the agent that spawned them, so a proxy cannot associate a child stream with its parent. Record a spawn-only `parentSessionId` in the session header from all three RLM child construction paths and read it back through `SessionManager.getParentSessionId()`, which joins `ReadonlySessionManager` so `ctx.sessionManager` exposes it. It names the immediate parent, and the scoped session view delegates it unchanged so derived requests report their owner's parent. fixes #34 --- .pylon/features.yaml | 16 ++ .pylon/upstream-review.md | 10 + .../.changes/34-parent-session-identity.md | 1 + packages/coding-agent/docs/extensions.md | 28 +++ packages/coding-agent/docs/session-format.md | 6 + .../src/core/agent-session-runtime.ts | 11 +- .../coding-agent/src/core/agent-session.ts | 14 +- .../src/core/extensions/runner.ts | 5 +- .../coding-agent/src/core/session-manager.ts | 20 ++ .../src/modes/daemon/daemon-mode.ts | 1 + .../34-parent-session-identity.test.ts | 207 ++++++++++++++++++ 11 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 packages/coding-agent/.changes/34-parent-session-identity.md create mode 100644 packages/coding-agent/test/suite/regressions/34-parent-session-identity.test.ts diff --git a/.pylon/features.yaml b/.pylon/features.yaml index 5bbd1ba632..357e430e35 100644 --- a/.pylon/features.yaml +++ b/.pylon/features.yaml @@ -335,3 +335,19 @@ decisions: revisit_when: - Prime upstream provides an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached tools/system/history prefix, including a declared constraint for append-only-history backends. - Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy or Meridian lineage continuation rates. + + parent-session-identity: + area: runtime-reliability + state: shipped + owner: pylon-prime-integration + decision: retain + pylon_refs: + - https://github.com/pylon-code/prime-agent/issues/22 + - https://github.com/pylon-code/prime-agent/issues/34 + upstream_refs: + - https://github.com/PrimeIntellect-ai/prime-agent/tree/c718bf3c30fd8da206ed551837cbb54f7ad15948 + fork_change: parent-session-identity-v1 + upstream_support: Prime through c718bf3c30fd knows a subagent's parent only as in-memory daemon runtime metadata and as trace headers derived by walking the parentSession file pointer. Its session header has no parentSessionId, ReadonlySessionManager has no getParentSessionId, and parentSession also records fork and new-session lineage, so no extension-visible surface can name the agent that spawned a session. + revisit_when: + - Prime records a spawn-only parent session id in the session header or exposes an equivalent accessor on the extension-visible session manager. + - The proxy-side parent-tree cancellation registry in rynfar/meridian#902 stops needing the parent edge inside the provider payload. diff --git a/.pylon/upstream-review.md b/.pylon/upstream-review.md index ed4b70478d..607193dec4 100644 --- a/.pylon/upstream-review.md +++ b/.pylon/upstream-review.md @@ -162,3 +162,13 @@ This ledger records Prime upstream evidence and the decision taken for each over - The fix is a declared per-model constraint, not a workaround. `Model.appendOnlyHistory` marks a backend that caches by session rather than by request prefix and therefore requires the request message array to stay byte-identical to the persisted history. When set, the API registry routes `volatileContext` into the system prompt and adds no payload-only message block; the backend's own session cache absorbs the system-prompt change. Default stays trailing placement, which is correct for direct prefix-cached APIs. The flag is settable from extension `registerProvider` model entries and from `models.json` model definitions and `modelOverrides`, and it is documented for extension authors in `packages/coding-agent/docs/extensions.md`. - Deferred: the optional debug-level prefix-drift check from issue #26 is not implemented. The regression tests assert the invariant directly, and a cross-turn byte comparison would need new mutable state inside otherwise pure prompt assembly. - Revisit when Prime upstream offers an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached prefix, and Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy. + +## 2026-08-31 — parent session identity for RLM child extension contexts + +- Upstream baseline: `PrimeIntellect-ai/prime-agent@c718bf3c30fd8da206ed551837cbb54f7ad15948`; this addition is client-local and does not advance `reviewed_upstream_commit`. +- Reviewed current upstream `session-manager.ts`, `extensions/runner.ts`, `extensions/types.ts`, `agent-session.ts`, `agent-session-runtime.ts`, `daemon-mode.ts`, and `agent-traces.ts`, plus upstream issue and pull-request searches for `parent_session_id`, `getParentSessionId`, and parent lineage on `before_provider_request`. Upstream tracks a subagent's parent only as in-memory daemon runtime metadata and as an `X-Parent-Session` trace header derived by walking the `parentSession` file pointer. Nothing extension-visible names the spawning agent, and `parentSession` is shared with fork and new-session lineage, so it cannot answer the question on its own. +- `parent-session-identity`: **retain**. The session header gains a spawn-only `parentSessionId`, written by all three RLM child construction paths (inline, `AgentSessionRuntime` host, daemon host). `SessionManager.getParentSessionId()` reads it and joins `ReadonlySessionManager`, so `ctx.sessionManager` exposes it without forking the read-only view. The scoped session view added for child-scoped provider identity delegates the accessor unchanged, so `/side:` and the summarization scopes report their owner's parent. +- Lineage decision: `parentSessionId` names the **immediate** parent, matching the existing trace `parentSessionId` at depth 0. A consumer that holds every session can walk the chain to the root; a root-only field cannot be reconstructed into one. +- Deliberate boundaries: fork, clone, and branch do not carry the linkage, because the result is an independent session with its own provider key rather than a live child of a running parent. Recording the parent id no longer depends on the parent being persisted, which also aligns the inline and `AgentSessionRuntime` hosts with the daemon host's unconditional `newSession` call and its correct child `rlmDepth`. +- Additive only: no daemon command, event, or response shape changes. The parent edge rides inside the `metadata.user_id` envelope extensions already produce. +- Validation: `npm run check` clean. `test/suite/regressions/34-parent-session-identity.test.ts` passes 5/5 and 4 of its 5 cases fail when the inline path stops recording the parent id. Adjacent suites pass: session-manager unit suites, SDK session manager, session flush and git state, migrations, agent traces, context tree, RLM ledger, saved-session catalog, agent-session recursion, child provider identity, subagent runtime host, subagent model selection, subagent terminal messages, side questions, fast-mode children, agent-session runtime, suite compaction, concurrent sessions, daemon agent connection, daemon session id, daemon lazy subagents, daemon session list, ACP RLM subagents, RLM subagent display, clone command, session cwd, and agents-view state — 875 passes across 42 files. `test/extensions-runner.test.ts` fails 21 of 28 identically on unmodified `origin/pylon`, an environment-level extension-loading failure unrelated to this change. diff --git a/packages/coding-agent/.changes/34-parent-session-identity.md b/packages/coding-agent/.changes/34-parent-session-identity.md new file mode 100644 index 0000000000..76e22b5f2c --- /dev/null +++ b/packages/coding-agent/.changes/34-parent-session-identity.md @@ -0,0 +1 @@ +- Added `ctx.sessionManager.getParentSessionId()` so extensions can report the session that spawned a subagent, and recorded it in the session header so a resumed subagent still reports its parent ([#34](https://github.com/pylon-code/prime-agent/issues/34)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 26f433f1c3..7254cc35b2 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -656,6 +656,24 @@ The hook fires for every provider request the session is responsible for, and `c Treat the id as opaque: match on the `` prefix if you need to group derived work with its session. +`ctx.sessionManager.getParentSessionId()` names the agent that spawned the session, so a handler can stamp the whole tree edge and not just one node: + +```typescript +pi.on("before_provider_request", (event, ctx) => { + return { + ...event.payload, + metadata: { + user_id: JSON.stringify({ + session_id: ctx.sessionManager.getSessionId(), + parent_session_id: ctx.sessionManager.getParentSessionId(), + }), + }, + }; +}); +``` + +See [ctx.sessionManager](#ctxsessionmanager) for what the parent id does and does not cover. + #### after_provider_response Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order. @@ -916,8 +934,18 @@ For `tool_call`, this state is synchronized through the current assistant messag ctx.sessionManager.getEntries() // All entries ctx.sessionManager.getBranch() // Current branch ctx.sessionManager.getLeafId() // Current leaf entry ID +ctx.sessionManager.getSessionId() // Identity of the conversation this request belongs to +ctx.sessionManager.getParentSessionId() // Session that spawned this one, or undefined ``` +`getParentSessionId()` describes the agent tree: + +- A root session returns `undefined`. A subagent returns the session id of the agent that spawned it, inline or in the daemon. +- It names the **immediate** parent. A grandchild reports its own parent, not the root, so a handler holding several sessions can walk the chain. +- It is recorded in the session header, so a resumed or reattached subagent still reports its parent. +- Scoped requests (`/side:`, `/compaction`, ...) report the parent of the session that owns them. The scope changes `getSessionId()` only. +- Forking or branching a session does not carry the linkage: the result is an independent session with its own provider identity, not a live child of a running parent. + ### ctx.modelRegistry / ctx.model Access to models and API keys. diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index 7437e65acb..9dbfb53416 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -196,6 +196,12 @@ For sessions with a parent (created via `/fork`, `/clone`, or `newSession({ pare {"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"} ``` +Subagent sessions also record `parentSessionId`, the session id of the agent that spawned them. `parentSession` is a file pointer that fork and new-session lineage share, so only `parentSessionId` identifies the immediate parent in an agent tree. It survives resume and is read back with `getParentSessionId()`. + +```json +{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/parent/session.jsonl","parentSessionId":"parent-uuid","rlmDepth":1} +``` + ### SessionMessageEntry A message in the conversation. The `message` field contains an `AgentMessage`. diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index c63731fe05..34db94b14a 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -314,12 +314,11 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { async createRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): Promise { const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir); - if (options.parentSession.sessionFile) { - sessionManager.newSession({ - parentSession: options.parentSession.sessionFile, - rlmDepth: options.rlmDepth, - }); - } + sessionManager.newSession({ + parentSession: options.parentSession.sessionFile, + parentSessionId: options.parentSession.sessionId, + rlmDepth: options.rlmDepth, + }); const runtime = await this.scopedBuild(() => createAgentSessionRuntime(this.createRuntime, { cwd: sessionManager.getCwd(), diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e9d9f5e71e..11981f22cf 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -10052,12 +10052,14 @@ export class AgentSession { private _createInlineRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): RlmSubagentRuntime { const childSessionManager = SessionManager.create(this._cwd, options.sessionDir); - if (options.parentSession.sessionFile) { - childSessionManager.newSession({ - parentSession: options.parentSession.sessionFile, - rlmDepth: options.rlmDepth, - }); - } + // Recorded unconditionally, matching the daemon host: an unpersisted parent + // has no file to point at, but the child still has to name the agent that + // spawned it so extensions can report the tree edge. + childSessionManager.newSession({ + parentSession: options.parentSession.sessionFile, + parentSessionId: options.parentSession.sessionId, + rlmDepth: options.rlmDepth, + }); childSessionManager.appendModelChange(options.model.provider, options.model.id); childSessionManager.appendThinkingLevelChange(options.thinkingLevel); childSessionManager.appendServiceTierChange(options.serviceTier); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index fe9595cac4..eb1511ebc9 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -236,13 +236,16 @@ const noOpUIContext: ExtensionUIContext = { * Read-only session view whose id identifies work derived from a session * rather than the session itself. Everything else still reports the owning * session, so an extension keyed on `getSessionId()` gets a distinct provider - * identity without losing access to the real transcript. + * identity without losing access to the real transcript. Notably + * `getParentSessionId()` still reports the owner's parent: scoped work belongs + * to the same place in the agent tree as the session that derived it. */ function scopedSessionManagerView(base: SessionManager, scope: string): ReadonlySessionManager { return { getCwd: () => base.getCwd(), getSessionDir: () => base.getSessionDir(), getSessionId: () => `${base.getSessionId()}/${scope}`, + getParentSessionId: () => base.getParentSessionId(), getSessionFile: () => base.getSessionFile(), getLeafId: () => base.getLeafId(), getLeafEntry: () => base.getLeafEntry(), diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index cd6e486deb..e2de48e3a9 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -79,6 +79,12 @@ export interface SessionHeader { timestamp: string; cwd: string; parentSession?: string; + /** + * Session id of the agent that spawned this one, set only for RLM children. + * `parentSession` is a file pointer that also records fork and new-session + * lineage, so it cannot answer "which live agent owns this child". + */ + parentSessionId?: string; rlmDepth?: number; git?: GitContext; } @@ -86,6 +92,7 @@ export interface SessionHeader { export interface NewSessionOptions { id?: string; parentSession?: string; + parentSessionId?: string; rlmDepth?: number; } @@ -272,6 +279,7 @@ export type ReadonlySessionManager = Pick< | "getEntries" | "getTree" | "getSessionName" + | "getParentSessionId" >; function createSessionId(): string { @@ -1216,6 +1224,7 @@ export class SessionManager { timestamp, cwd: this.cwd, parentSession: options?.parentSession, + parentSessionId: options?.parentSessionId, rlmDepth, git, }; @@ -1310,6 +1319,16 @@ export class SessionManager { return this.sessionId; } + /** + * Session id of the agent that spawned this session, or undefined for a root + * session. Names the immediate parent, not the root of a deeper RLM tree, so + * a consumer holding every session can walk the chain. Lives in the session + * header, so a resumed child still reports its parent. + */ + getParentSessionId(): string | undefined { + return this.getHeader()?.parentSessionId; + } + getSessionFile(): string | undefined { return this.sessionFile; } @@ -1337,6 +1356,7 @@ export class SessionManager { timestamp, cwd: this.cwd, parentSession: previousHeader?.parentSession, + parentSessionId: previousHeader?.parentSessionId, rlmDepth: resolveSessionRlmDepth(previousHeader ?? {}, target.sessionFile), git, }; diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 2c01240580..35688f3641 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -2463,6 +2463,7 @@ export class AgentDaemon { const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir); sessionManager.newSession({ parentSession: options.parentSession.sessionFile, + parentSessionId: options.parentSession.sessionId, rlmDepth: options.rlmDepth, }); let stateRef: ActiveSessionState | undefined; diff --git a/packages/coding-agent/test/suite/regressions/34-parent-session-identity.test.ts b/packages/coding-agent/test/suite/regressions/34-parent-session-identity.test.ts new file mode 100644 index 0000000000..750b4571eb --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/34-parent-session-identity.test.ts @@ -0,0 +1,207 @@ +import { type FauxRequestPayload, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentSession } from "../../../src/core/agent-session.js"; +import { SessionManager } from "../../../src/core/session-manager.js"; +import { startSideQuestion } from "../../../src/core/side-question.js"; +import type { ExtensionAPI } from "../../../src/index.js"; +import { createHarness, type Harness } from "../harness.js"; + +interface Lineage { + sessionId: string; + parentSessionId?: string; +} + +/** + * Stands in for the Meridian extension: `metadata.user_id` is the only session + * identity channel Anthropic-protocol providers have, and the proxy-side + * cancellation registry needs the parent edge in the same envelope. + */ +function stampLineage(observed: Lineage[]) { + return (pi: ExtensionAPI) => { + pi.on("before_provider_request", (event, ctx) => { + const lineage: Lineage = { + sessionId: ctx.sessionManager.getSessionId(), + parentSessionId: ctx.sessionManager.getParentSessionId(), + }; + observed.push(lineage); + return { + ...(event.payload as FauxRequestPayload), + metadata: { + user_id: JSON.stringify({ + session_id: lineage.sessionId, + parent_session_id: lineage.parentSessionId, + }), + }, + }; + }); + }; +} + +function sentLineages(harness: Harness): Lineage[] { + return harness.faux.getSentPayloads().map((payload) => { + const userId = (payload as FauxRequestPayload).metadata?.user_id; + if (typeof userId !== "string") { + return { sessionId: "" }; + } + const envelope = JSON.parse(userId) as { session_id: string; parent_session_id?: string }; + return { sessionId: envelope.session_id, parentSessionId: envelope.parent_session_id }; + }); +} + +function requireChildSession(session: AgentSession, childId: string): AgentSession { + const child = session.getRlmChildSession(childId); + if (!child) { + throw new Error(`RLM child session ${childId} was never published`); + } + return child; +} + +async function spawnAnsweredChild(parent: AgentSession, prompt: string): Promise { + const handle = await parent.runRlmChild(prompt); + await vi.waitFor(() => { + expect(requireChildSession(parent, handle.rlm_child_id).getLastAssistantText()).toBe("child answer"); + }); + return requireChildSession(parent, handle.rlm_child_id); +} + +function lineageOf(observed: Lineage[], sessionId: string): Lineage | undefined { + return observed.find((lineage) => lineage.sessionId === sessionId); +} + +describe("#34 parent session identity in extension contexts", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("reports no parent for a root session and the spawning session for an inline RLM child", async () => { + const observed: Lineage[] = []; + const harness = await createHarness({ + persistSession: true, + extensionFactories: [stampLineage(observed)], + }); + harnesses.push(harness); + + harness.setResponses([fauxAssistantMessage("parent answer")]); + await harness.session.prompt("parent turn"); + const rootSessionId = harness.session.sessionId; + expect(observed).toEqual([{ sessionId: rootSessionId, parentSessionId: undefined }]); + + harness.setResponses([fauxAssistantMessage("child answer"), fauxAssistantMessage("parent noted")]); + const child = await spawnAnsweredChild(harness.session, "child task"); + + expect(child.sessionId).not.toBe(rootSessionId); + expect(lineageOf(observed, child.sessionId)).toEqual({ + sessionId: child.sessionId, + parentSessionId: rootSessionId, + }); + // The root keeps reporting no parent, including on its terminal-notice turn. + for (const lineage of observed.filter((entry) => entry.sessionId === rootSessionId)) { + expect(lineage.parentSessionId).toBeUndefined(); + } + // Both fields reach the provider payload inside the envelope the extension + // stamped, which is the contract the proxy registry reads. + expect(sentLineages(harness)).toEqual(observed); + }); + + it("names the immediate parent, not the root, for a grandchild", async () => { + const observed: Lineage[] = []; + const harness = await createHarness({ + persistSession: true, + extensionFactories: [stampLineage(observed)], + }); + harnesses.push(harness); + + // Uniform answers: the child, the grandchild, and the terminal-notice turns + // they trigger on their parents all draw from this one queue. + harness.setResponses(Array.from({ length: 6 }, () => fauxAssistantMessage("child answer"))); + const rootSessionId = harness.session.sessionId; + const child = await spawnAnsweredChild(harness.session, "child task"); + const grandchild = await spawnAnsweredChild(child, "grandchild task"); + + expect(new Set([rootSessionId, child.sessionId, grandchild.sessionId]).size).toBe(3); + expect(lineageOf(observed, grandchild.sessionId)).toEqual({ + sessionId: grandchild.sessionId, + parentSessionId: child.sessionId, + }); + expect(sentLineages(harness)).toEqual(observed); + }); + + it("reports the owning session's parent for a scoped auxiliary request", async () => { + const observed: Lineage[] = []; + const harness = await createHarness({ + persistSession: true, + extensionFactories: [stampLineage(observed)], + }); + harnesses.push(harness); + + harness.setResponses([fauxAssistantMessage("child answer"), fauxAssistantMessage("parent noted")]); + const rootSessionId = harness.session.sessionId; + const child = await spawnAnsweredChild(harness.session, "child task"); + + harness.setResponses([fauxAssistantMessage("side answer")]); + const run = startSideQuestion(child, "question-1", "A side question?", () => {}); + await run.done; + + // The scope changes only the id; the parent edge still describes the owner. + expect(lineageOf(observed, `${child.sessionId}/side:question-1`)).toEqual({ + sessionId: `${child.sessionId}/side:question-1`, + parentSessionId: rootSessionId, + }); + expect(sentLineages(harness)).toEqual(observed); + }); + + it("reports no parent for a root session's compaction scope", async () => { + const observed: Lineage[] = []; + const harness = await createHarness({ + settings: { compaction: { keepRecentTokens: 1 } }, + persistSession: true, + extensionFactories: [stampLineage(observed)], + }); + harnesses.push(harness); + + harness.setResponses([ + fauxAssistantMessage("one response"), + fauxAssistantMessage("two response"), + fauxAssistantMessage("model-generated summary"), + fauxAssistantMessage("model-generated turn summary"), + ]); + await harness.session.prompt("one"); + await harness.session.prompt("two"); + const rootSessionId = harness.session.sessionId; + const beforeCompaction = observed.length; + + await harness.session.compact(); + + const summarization = observed.slice(beforeCompaction); + expect(summarization.length).toBeGreaterThan(0); + for (const lineage of summarization) { + expect(lineage).toEqual({ sessionId: `${rootSessionId}/compaction`, parentSessionId: undefined }); + } + expect(sentLineages(harness)).toEqual(observed); + }); + + it("keeps the parent id when a child session is reopened from disk", async () => { + const observed: Lineage[] = []; + const harness = await createHarness({ + persistSession: true, + extensionFactories: [stampLineage(observed)], + }); + harnesses.push(harness); + + harness.setResponses([fauxAssistantMessage("child answer"), fauxAssistantMessage("parent noted")]); + const rootSessionId = harness.session.sessionId; + const child = await spawnAnsweredChild(harness.session, "child task"); + const childSessionFile = child.sessionFile; + if (childSessionFile === undefined) { + throw new Error("Persisted RLM child is missing a session file"); + } + + const reopened = SessionManager.open(childSessionFile); + expect(reopened.getSessionId()).toBe(child.sessionId); + expect(reopened.getParentSessionId()).toBe(rootSessionId); + }); +});