From ef5e5fd27c3a8ec306ed4e3bee5036beda31e680 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 17 Jun 2026 00:21:20 +0200 Subject: [PATCH] fix(coding-agent): fixed parked subagent restoration from persisted sessions - Fixed cold revival flow so parked subagents are restored from persisted sessions at startup. - Fixed session-init persistence to include spawns and readSummarize fields for replay accuracy. - Fixed latest-session lookup by adding peekSessionInit for lock-free persisted contract access. - Added lifecycle and session tests for cold-revive success, decline, and retry paths. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/main.ts | 20 +++ .../src/registry/agent-lifecycle.ts | 68 ++++++++-- .../src/session/session-entries.ts | 4 + .../src/session/session-manager.ts | 65 ++++++++- packages/coding-agent/src/task/executor.ts | 2 + .../coding-agent/src/task/persisted-revive.ts | 128 ++++++++++++++++++ .../test/registry/agent-lifecycle.test.ts | 75 ++++++++++ .../test/session/peek-session-init.test.ts | 84 ++++++++++++ 9 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/src/task/persisted-revive.ts create mode 100644 packages/coding-agent/test/session/peek-session-init.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 775d6f6c6..f9b368e3c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -33,6 +33,7 @@ - Fixed `omp --continue`/`-c` sometimes resuming into a subagent transcript instead of the interactive session. Subagent (and HTML-export) `SessionManager.open()` calls run in the parent's terminal and were clobbering the per-TTY `--continue` breadcrumb with their own artifact-dir session file; these headless opens now suppress the breadcrumb. `continueRecent()` also recovers already-poisoned breadcrumbs by resolving any session file inside a parent's artifacts dir (`/.jsonl`) back up to the top-level session. - Fixed the Agent Hub stacking duplicate `Agent Hub · N running` frames and stranding garbage rows in scrollback while navigating with subagents still streaming. The hub was a non-fullscreen overlay composited over a live transcript, so each time a running subagent's progress grew the frame and scrolled the window the previously-painted hub copy was pushed permanently into the terminal's native scrollback (which the engine can't rewrite). It now renders inline in the editor slot — the same anchored region every other selector and the `ask` tool use — riding the normal append-only commit path, so the transcript commits above it exactly once and the hub repaints in place instead of leaking copies. (Avoids borrowing the alternate screen.) - Fixed every subagent registering itself as its own parent in the agent registry (`parentId === id`), so the Agent Hub rendered each agent as `sub · of ` and the ←← parent-navigation gesture looped on the same agent. The SDK was reusing `parentTaskPrefix` — the agent's own artifact/output-id prefix — as the registry parent link; spawns now pass a separate `parentAgentId` (the spawning agent's id: `Main` for top-level `task` spawns, the parent subagent for nested spawns and eval `agent()`, the focused agent for `/tan`) and the registry records that as the parent. +- Fixed messaging a `parked` subagent that was restored from disk (Agent Hub scan, or a resumed/restarted session) failing with `cannot be revived (no reviver registered)` even though its transcript was intact. Such refs carry a session file but no in-memory reviver — the executor's live reviver closure dies with the spawning turn/process — so IRC sends and Agent Hub focus refused them. `AgentLifecycleManager.ensureLive` now cold-revives them through a persisted-subagent reviver factory (installed by the top-level interactive/RPC session) that rebuilds the subagent from its JSONL the way `--resume` rebuilds a session: it reopens the file and replays it through `createAgentSession`, but sources the runtime contract from a now-readable `session_init` record (`SessionManager.peekSessionInit`) so tools, system prompt, output schema, and kind are restored rather than resurrected as a default top-level session. `session_init` now also persists the effective `spawns` allowlist and read-summarization flag so a cold revive keeps the original capability surface (old files without them deny re-spawning rather than defaulting to wildcard). Isolated runs and pre-`session_init` files whose recorded workspace no longer exists stay transcript-only (`history://`). ## [16.0.2] - 2026-06-16 diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c336e2e2f..b730a9b46 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -55,6 +55,7 @@ import type { PrintModeOptions } from "./modes/print-mode"; import { CURRENT_SETUP_VERSION } from "./modes/setup-version"; import { initTheme, stopThemeWatcher } from "./modes/theme/theme"; import type { SubmittedUserInput } from "./modes/types"; +import { AgentLifecycleManager } from "./registry/agent-lifecycle"; import { type CreateAgentSessionOptions, type CreateAgentSessionResult, @@ -68,6 +69,7 @@ import { resolveResumableSession, type SessionInfo } from "./session/session-lis import { SessionManager } from "./session/session-manager"; import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt"; +import { createPersistedSubagentReviverFactory } from "./task/persisted-revive"; import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export"; import { AUTO_THINKING } from "./thinking"; import type { LspStartupServerInfo } from "./tools"; @@ -1262,6 +1264,24 @@ export async function runRootCommand( eventBus, preloadedExtensions: extensionsResult, }); + + // Cold-revive support: a `parked` subagent ref restored from disk (Agent Hub + // scan, collab mirror, resumed process) has a sessionFile but no in-memory + // reviver, so `ensureLive` (IRC sends, hub focus) would refuse it. Install a + // factory — bound to THIS top-level session — that rebuilds the subagent from + // its persisted JSONL (see persisted-revive.ts). Scoped to the non-ACP + // bootstrap: ACP keeps several concurrent top-level sessions and a single + // process-global factory must not be clobbered by the most recent one. + AgentLifecycleManager.global().setPersistedSubagentReviverFactory( + createPersistedSubagentReviverFactory({ + session, + authStorage, + modelRegistry, + settings: settingsInstance, + enableLsp: sessionOptions.enableLsp ?? true, + }), + Math.trunc(Number(settingsInstance.get("task.agentIdleTtlMs") ?? 420_000) || 0), + ); if (parsedArgs.apiKey && !sessionOptions.model && session.model) { authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey); } diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts index ad2ef4f16..91678e2a7 100644 --- a/packages/coding-agent/src/registry/agent-lifecycle.ts +++ b/packages/coding-agent/src/registry/agent-lifecycle.ts @@ -12,10 +12,19 @@ import { logger } from "@oh-my-pi/pi-utils"; import type { AgentSession } from "../session/agent-session"; -import { AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "./agent-registry"; +import { type AgentRef, AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "./agent-registry"; export type AgentReviver = () => Promise; +/** + * Builds a reviver for a `parked` ref restored from disk (Agent Hub scan, + * collab mirror, resumed process) that carries a sessionFile but no in-memory + * adoption. Returns undefined when the ref cannot be faithfully rebuilt (no + * persisted session contract, or its workspace is gone). Injected from the + * top-level session so this manager stays free of sdk/SessionManager imports. + */ +export type PersistedSubagentReviverFactory = (ref: AgentRef) => Promise; + export interface AdoptOptions { /** TTL before an idle agent is parked. <= 0 disables parking. */ idleTtlMs: number; @@ -51,6 +60,7 @@ export class AgentLifecycleManager { current.#adopted.clear(); current.#revivals.clear(); current.#parking.clear(); + current.#persistedReviverFactory = undefined; } AgentLifecycleManager.#global = undefined; } @@ -62,12 +72,26 @@ export class AgentLifecycleManager { /** In-flight revives, so concurrent {@link ensureLive} calls coalesce. */ readonly #revivals = new Map>(); #unsubscribe: (() => void) | undefined; + #persistedReviverFactory: PersistedSubagentReviverFactory | undefined; + /** TTL applied when a cold-revived ref is adopted on demand. */ + #persistedReviveTtlMs = 0; constructor(registry: AgentRegistry = AgentRegistry.global()) { this.#registry = registry; this.#unsubscribe = registry.onChange(event => this.#onRegistryEvent(event)); } + /** + * Install the factory used to cold-revive `parked` refs restored from disk + * (Agent Hub scan, collab mirror, resumed process) — they carry a sessionFile + * but no adoption. Set by the top-level session, which owns the ambient deps + * (auth, models, MCP, artifacts) the factory needs at revive time. + */ + setPersistedSubagentReviverFactory(factory: PersistedSubagentReviverFactory, idleTtlMs: number): void { + this.#persistedReviverFactory = factory; + this.#persistedReviveTtlMs = idleTtlMs; + } + /** * Take ownership of a finished subagent. Caller has already set registry * status to "idle". Arms the TTL timer (idleTtlMs <= 0 adopts without one). @@ -137,13 +161,7 @@ export class AgentLifecycleManager { if (ref.session) return ref.session; const inflight = this.#revivals.get(id); if (inflight) return inflight; - const adopted = this.#adopted.get(id); - if (ref.status !== "parked" || !adopted?.revive) { - throw new Error( - `Agent "${id}" is ${ref.status} and cannot be revived${adopted?.revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`, - ); - } - const revival = this.#revive(id, adopted.revive, ref.sessionFile); + const revival = this.#resolveAndRevive(id, ref); this.#revivals.set(id, revival); try { return await revival; @@ -152,6 +170,39 @@ export class AgentLifecycleManager { } } + /** + * Resolve a reviver and bring the agent back to a live session. A ref + * restored from disk is `parked` with a sessionFile but no in-memory + * adoption; build a reviver via the injected persisted-subagent factory and + * adopt it so the agent rejoins the normal idle↔parked lifecycle. Throws + * when the agent is not revivable or no reviver can be produced. + */ + async #resolveAndRevive(id: string, ref: AgentRef): Promise { + let revive = this.#adopted.get(id)?.revive; + let coldAdopted = false; + if (!revive && ref.status === "parked" && ref.sessionFile && this.#persistedReviverFactory) { + revive = await this.#persistedReviverFactory(ref); + if (revive) { + this.#adopted.set(id, { idleTtlMs: this.#persistedReviveTtlMs, revive }); + coldAdopted = true; + } + } + if (ref.status !== "parked" || !revive) { + throw new Error( + `Agent "${id}" is ${ref.status} and cannot be revived${revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`, + ); + } + try { + return await this.#revive(id, revive, ref.sessionFile); + } catch (error) { + // A failed cold revive (stale ctx, missing cwd, bad MCP) must not leave a + // poisoned reviver stuck in #adopted — drop it so a later ensureLive + // rebuilds via the factory (which may have fresher context by then). + if (coldAdopted) this.#adopted.delete(id); + throw error; + } + } + /** Hard removal: dispose if live, unregister from registry, drop timers. */ async release(id: string): Promise { const adopted = this.#adopted.get(id); @@ -176,6 +227,7 @@ export class AgentLifecycleManager { await Promise.all(ids.map(id => this.release(id))); this.#revivals.clear(); this.#parking.clear(); + this.#persistedReviverFactory = undefined; } async #revive(id: string, revive: AgentReviver, sessionFile: string | null): Promise { diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 099a15938..5b180c9ef 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -124,6 +124,10 @@ export interface SessionInitEntry extends SessionEntryBase { tools: string[]; /** Output schema if structured output was requested */ outputSchema?: unknown; + /** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */ + spawns?: string; + /** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */ + readSummarize?: boolean; } /** Mode change entry - tracks agent mode transitions (e.g. plan mode). */ diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 17ee20bbb..b35e0041e 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1178,7 +1178,14 @@ export class SessionManager { return entry.id; } - appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string { + appendSessionInit(init: { + systemPrompt: string; + task: string; + tools: string[]; + outputSchema?: unknown; + spawns?: string; + readSummarize?: boolean; + }): string { const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init }; this.#recordEntry(entry); return entry.id; @@ -1561,6 +1568,62 @@ export class SessionManager { return manager; } + /** + * Lock-free peek for cold subagent revival: returns the recorded working + * directory (session header) and the latest `session_init` contract (system + * prompt / tools / output schema) WITHOUT taking the single-writer lock that + * {@link open} acquires — the caller re-opens for the actual revive. Returns + * null when the file can't be read; `init` is null for files written before + * `session_init` was recorded (no faithful contract to rebuild from). + */ + static async peekSessionInit( + filePath: string, + storage: SessionStorage = new FileSessionStorage(), + ): Promise<{ + cwd: string; + init: { + systemPrompt: string; + task: string; + tools: string[]; + outputSchema?: unknown; + spawns?: string; + readSummarize?: boolean; + } | null; + } | null> { + let loaded: FileEntry[]; + try { + loaded = await loadEntriesFromFile(filePath, storage); + } catch { + return null; + } + // A missing/empty file has no usable session — nothing to revive from. + if (loaded.length === 0) return null; + const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined; + let init: { + systemPrompt: string; + task: string; + tools: string[]; + outputSchema?: unknown; + spawns?: string; + readSummarize?: boolean; + } | null = null; + for (let index = loaded.length - 1; index >= 0; index--) { + const entry = loaded[index]; + if (entry.type === "session_init") { + init = { + systemPrompt: entry.systemPrompt, + task: entry.task, + tools: entry.tools, + outputSchema: entry.outputSchema, + readSummarize: entry.readSummarize, + spawns: entry.spawns, + }; + break; + } + } + return { cwd: header?.cwd ?? getProjectDir(), init }; + } + /** Continue the most recent session, or create a new one if none exists. */ static async continueRecent( cwd: string, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 694ceefe2..de20edc2c 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2098,6 +2098,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + const sessionFile = ref.sessionFile; + if (!sessionFile) return undefined; + const peek = await SessionManager.peekSessionInit(sessionFile); + // No persisted contract (pre-session_init file) or the recorded workspace + // is gone (isolated/merged worktree, moved dir): leave it transcript-only + // (history://) rather than resurrect a wrong or broken session. + if (!peek?.init) return undefined; + try { + await fs.stat(peek.cwd); + } catch { + return undefined; + } + const init = peek.init; + // taskDepth drives real capability gating (task-spawn allowance, memory + // startup, …); derive it from the persisted parent chain rather than + // assuming a fixed level. + let taskDepth = 1; + let parentId = ref.parentId; + const seen = new Set(); + while (parentId && parentId !== MAIN_AGENT_ID && !seen.has(parentId)) { + seen.add(parentId); + taskDepth++; + parentId = registry.get(parentId)?.parentId; + } + return async () => { + // Re-open fresh on every revive: park closes the writer, so this takes + // the single-writer lock cleanly and restores the full message history. + const reopened = await SessionManager.open(sessionFile, undefined, undefined, { + suppressBreadcrumb: true, + }); + const artifactManager = ctx.session.sessionManager.getArtifactManager(); + if (artifactManager) reopened.adoptArtifactManager(artifactManager); + // Reuse the parent's live MCP connections via proxy tools (no + // re-discovery), exactly as the executor does for live subagents. + const mcpManager = MCPManager.instance(); + const mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : []; + const { session } = await createAgentSession({ + cwd: ctx.session.sessionManager.getCwd(), + authStorage: ctx.authStorage, + modelRegistry: ctx.modelRegistry, + settings: createSubagentSettings( + ctx.settings, + init.readSummarize === false ? { "read.summarize.enabled": false } : undefined, + ), + sessionManager: reopened, + agentId: ref.id, + agentDisplayName: ref.displayName, + parentTaskPrefix: ref.id, + parentAgentId: ref.parentId, + taskDepth, + toolNames: init.tools, + outputSchema: init.outputSchema, + requireYieldTool: true, + systemPrompt: () => [init.systemPrompt], + // Old files predate persisted spawns: deny re-spawning rather than let + // createAgentSession default to wildcard ("*"). + spawns: init.spawns ?? "", + hasUI: false, + enableLsp: ctx.enableLsp, + enableMCP: !mcpManager, + mcpManager, + customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined, + }); + // Clamp the active set to the persisted list: createAgentSession's + // `alwaysInclude` can re-add non-defaultInactive extension/custom tools + // the original run didn't carry. Unknown/missing names are ignored. + await session.setActiveToolsByName(init.tools); + // Cold revives must drive registry status themselves — createAgentSession + // doesn't wire this generically (the live path does it in the executor). + // Without it the idle-TTL timer never clears on a turn and the lifecycle + // could park the agent mid-run. + session.subscribe(event => { + if (event.type === "agent_start") registry.setStatus(ref.id, "running"); + else if (event.type === "agent_end") registry.setStatus(ref.id, "idle"); + }); + return session; + }; + }; +} diff --git a/packages/coding-agent/test/registry/agent-lifecycle.test.ts b/packages/coding-agent/test/registry/agent-lifecycle.test.ts index 2170b33bb..577ab4467 100644 --- a/packages/coding-agent/test/registry/agent-lifecycle.test.ts +++ b/packages/coding-agent/test/registry/agent-lifecycle.test.ts @@ -156,6 +156,81 @@ describe("AgentLifecycleManager", () => { await expect(lifecycle.ensureLive("5-Sub")).rejects.toThrow(/cannot be revived.*no reviver registered/); }); + it("ensureLive cold-revives a parked ref via the persisted factory and rejoins the lifecycle", async () => { + vi.useFakeTimers(); + const revived = makeSessionStub(); + // Restored from disk (hub scan / resume): parked with a sessionFile but NEVER adopted. + registry.register({ + id: "6-Sub", + displayName: "task", + kind: "sub", + session: null, + sessionFile: "/tmp/6-Sub.jsonl", + status: "parked", + }); + let factoryCalls = 0; + lifecycle.setPersistedSubagentReviverFactory(async () => { + factoryCalls++; + return async () => revived.session; + }, TTL); + + const session = await lifecycle.ensureLive("6-Sub"); + + expect(factoryCalls).toBe(1); + expect(session).toBe(revived.session); + expect(registry.get("6-Sub")?.status).toBe("idle"); + expect(registry.get("6-Sub")?.session).toBe(revived.session); + + // Adopted on demand with the configured TTL: it re-parks like any idle subagent. + vi.advanceTimersByTime(TTL); + await flushAsync(); + expect(registry.get("6-Sub")?.status).toBe("parked"); + expect(revived.disposeCalls()).toBe(1); + }); + + it("a persisted factory that declines leaves the parked ref transcript-only", async () => { + registry.register({ + id: "7-Sub", + displayName: "task", + kind: "sub", + session: null, + sessionFile: "/tmp/7-Sub.jsonl", + status: "parked", + }); + lifecycle.setPersistedSubagentReviverFactory(async () => undefined, TTL); + + await expect(lifecycle.ensureLive("7-Sub")).rejects.toThrow(/cannot be revived.*no reviver registered/); + }); + + it("a failed cold revive is not sticky: the next ensureLive re-runs the factory", async () => { + const revived = makeSessionStub(); + registry.register({ + id: "8-Sub", + displayName: "task", + kind: "sub", + session: null, + sessionFile: "/tmp/8-Sub.jsonl", + status: "parked", + }); + let factoryCalls = 0; + lifecycle.setPersistedSubagentReviverFactory(async () => { + factoryCalls++; + const failFirst = factoryCalls === 1; + return async () => { + if (failFirst) throw new Error("stale context"); + return revived.session; + }; + }, TTL); + + await expect(lifecycle.ensureLive("8-Sub")).rejects.toThrow(/stale context/); + expect(registry.get("8-Sub")?.status).toBe("parked"); + + const session = await lifecycle.ensureLive("8-Sub"); + expect(factoryCalls).toBe(2); + expect(session).toBe(revived.session); + expect(registry.get("8-Sub")?.status).toBe("idle"); + }); + it("release disposes a live adopted agent, unregisters it, and leaves no pending park", async () => { vi.useFakeTimers(); const stub = makeSessionStub(); diff --git a/packages/coding-agent/test/session/peek-session-init.test.ts b/packages/coding-agent/test/session/peek-session-init.test.ts new file mode 100644 index 000000000..2ff4d38a3 --- /dev/null +++ b/packages/coding-agent/test/session/peek-session-init.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const tempDirs: TempDir[] = []; + +function makeTempDir(prefix: string): string { + const dir = TempDir.createSync(prefix); + tempDirs.push(dir); + return dir.path(); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => dir.remove())); +}); + +function assistantMessage(text: string) { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected built-in anthropic model to exist"); + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +describe("SessionManager.peekSessionInit", () => { + it("returns the latest session_init contract (tools/spawns/readSummarize) and the header cwd", async () => { + const cwd = makeTempDir("@pi-peek-cwd-"); + const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file path"); + + manager.appendSessionInit({ systemPrompt: "first", task: "t1", tools: ["read"], spawns: "" }); + manager.appendSessionInit({ + systemPrompt: "second", + task: "t2", + tools: ["read", "bash", "yield"], + spawns: "task", + readSummarize: false, + }); + // Flush buffered entries (header + inits) so the lock-free peek can read them off disk. + manager.appendMessage(assistantMessage("flush")); + + const peek = await SessionManager.peekSessionInit(sessionFile); + expect(peek?.cwd).toBe(manager.getCwd()); + // Latest init wins — the reviver must rebuild from the most recent contract. + expect(peek?.init?.systemPrompt).toBe("second"); + expect(peek?.init?.tools).toEqual(["read", "bash", "yield"]); + expect(peek?.init?.spawns).toBe("task"); + expect(peek?.init?.readSummarize).toBe(false); + }); + + it("returns init: null for a session file with no session_init (a main/legacy session)", async () => { + const cwd = makeTempDir("@pi-peek-legacy-"); + const manager = SessionManager.create(cwd, path.join(cwd, "sessions")); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Expected a persisted session file path"); + manager.appendMessage(assistantMessage("hi")); + + const peek = await SessionManager.peekSessionInit(sessionFile); + expect(peek?.cwd).toBe(manager.getCwd()); + expect(peek?.init).toBeNull(); + }); + + it("returns null for a file that cannot be read", async () => { + const peek = await SessionManager.peekSessionInit(path.join(makeTempDir("@pi-peek-missing-"), "nope.jsonl")); + expect(peek).toBeNull(); + }); +});