From ea93012be478d7c5909b3ff9b840a957c27b4c48 Mon Sep 17 00:00:00 2001 From: Anatoli Tsinovoy Date: Sat, 15 Aug 2026 19:24:54 +0300 Subject: [PATCH 1/2] fix(coding-agent): preserve artifacts in CLI forks --- packages/coding-agent/CHANGELOG.md | 4 +++ .../controllers/tan-command-controller.ts | 1 + .../coding-agent/src/session/agent-session.ts | 21 ++---------- .../src/session/session-manager.ts | 33 ++++++++++++++++--- .../tan-command-controller.test.ts | 2 +- .../test/session/session-manager-fork.test.ts | 31 +++++++++++++++++ 6 files changed, 68 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5597d222b..7c1e4d78e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `omp --fork` omitting the source session's artifact directory, so CLI-created forks now preserve `artifact://` references like interactive `/fork`. + ## [17.3.4] - 2026-08-14 ### Changed diff --git a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts index 4e9ea758f..1bbb13266 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -111,6 +111,7 @@ export class TanCommandController { let jobId = ""; try { const cloneManager = await SessionManager.forkFrom(parentFile, cwd, sessionDir, undefined, { + copyArtifacts: false, suppressBreadcrumb: true, sessionFile: cloneFile, }); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cd5679440..94df098e7 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -328,7 +328,7 @@ import { SessionMaintenance, type SessionMaintenanceHost, } from "./session-maintenance"; -import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager"; +import { cleanupEmptyMoveSession, copySessionArtifacts, type SessionManager } from "./session-manager"; import { SessionMemory, type SessionMemoryHost } from "./session-memory"; import { buildSessionMetadata } from "./session-metadata"; import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./session-provider-boundary"; @@ -6701,24 +6701,7 @@ export class AgentSession { // under a fresh id, so the work already produced is still this session's. this.#recovery.reanchorServedAttribution(previousSessionId); - // Copy artifacts directory if it exists - const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6); - const newArtifactDir = forkResult.newSessionFile.slice(0, -6); - - try { - const oldDirStat = await fs.promises.stat(oldArtifactDir); - if (oldDirStat.isDirectory()) { - await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true }); - } - } catch (err) { - if (!isEnoent(err)) { - logger.warn("Failed to copy artifacts during fork", { - oldArtifactDir, - newArtifactDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } + await copySessionArtifacts(forkResult.oldSessionFile, forkResult.newSessionFile); // Update agent session ID this.#freshProviderSessionId = undefined; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 37a50703d..05814a687 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -100,6 +100,28 @@ function artifactsDirectoryFor(sessionFile: string | undefined): string | null { return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null; } +/** Copy a session's artifact directory to another session, matching interactive `/fork`. */ +export async function copySessionArtifacts(sourceSessionFile: string, destinationSessionFile: string): Promise { + const sourceArtifactsDir = artifactsDirectoryFor(sourceSessionFile)!; + const destinationArtifactsDir = artifactsDirectoryFor(destinationSessionFile)!; + if (path.resolve(sourceArtifactsDir) === path.resolve(destinationArtifactsDir)) return; + + try { + const sourceStat = await fs.promises.stat(sourceArtifactsDir); + if (sourceStat.isDirectory()) { + await fs.promises.cp(sourceArtifactsDir, destinationArtifactsDir, { recursive: true }); + } + } catch (error) { + if (!isEnoent(error)) { + logger.warn("Failed to copy artifacts during fork", { + sourceArtifactsDir, + destinationArtifactsDir, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + /** * Resolve a breadcrumb's recorded session file to its interactive root. Subagent * (and other artifact) sessions live inside a parent session's artifacts dir — @@ -2533,16 +2555,16 @@ export class SessionManager { * session file while creating a fresh session file in this sessionDir. * * `options.sessionFile` pins the new session's file path (default: an - * auto-named `_.jsonl` in `sessionDir`). Callers that register - * the fork as a named agent (e.g. `/tan`) pass `.jsonl` so the - * persisted-subagent scan keys the agent by the same id the live ref uses. + * auto-named `_.jsonl` in `sessionDir`). Artifacts are copied + * recursively by default; nested agents that deliberately share their parent's + * artifact root may disable this with `copyArtifacts: false`. */ static async forkFrom( sourcePath: string, cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), - options?: { suppressBreadcrumb?: boolean; sessionFile?: string }, + options?: { copyArtifacts?: boolean; suppressBreadcrumb?: boolean; sessionFile?: string }, ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); const manager = new SessionManager(cwd, dir, true, storage); @@ -2575,6 +2597,9 @@ export class SessionManager { manager.sanitizeLoadedOpenAIResponsesReplayMetadata(); manager.#forceFileCreation = true; await manager.#rewriteAtomically(); + if (options?.copyArtifacts !== false) { + await copySessionArtifacts(sourcePath, manager.#sessionFile!); + } return manager; } diff --git a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts index 0f2e4cc8a..50a23d40e 100644 --- a/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts @@ -211,7 +211,7 @@ describe("TanCommandController", () => { harness.tempDir.path(), harness.parentFile.slice(0, -6), undefined, - { suppressBreadcrumb: true, sessionFile: expect.stringMatching(/Tan-.+\.jsonl$/) }, + { copyArtifacts: false, suppressBreadcrumb: true, sessionFile: expect.stringMatching(/Tan-.+\.jsonl$/) }, ); expect(harness.register).toHaveBeenCalledWith("task", "/tan write the release note", expect.any(Function), { ownerId: MAIN_AGENT_ID, diff --git a/packages/coding-agent/test/session/session-manager-fork.test.ts b/packages/coding-agent/test/session/session-manager-fork.test.ts index ed99247fd..2e3193434 100644 --- a/packages/coding-agent/test/session/session-manager-fork.test.ts +++ b/packages/coding-agent/test/session/session-manager-fork.test.ts @@ -87,4 +87,35 @@ describe("SessionManager.forkFrom", () => { setAgentDir(previousAgentDir); } }); + + it("copies source artifacts recursively into the fork by default", async () => { + using tempDir = TempDir.createSync("@omp-session-fork-artifacts-"); + const cwd = path.join(tempDir.path(), "project"); + const sessionDir = path.join(tempDir.path(), "sessions"); + const sourceFile = path.join(sessionDir, "source.jsonl"); + const sourceArtifactsDir = sourceFile.slice(0, -".jsonl".length); + const timestamp = new Date().toISOString(); + const sourceHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "source-with-artifacts", + timestamp, + cwd, + }; + await fs.mkdir(path.join(sourceArtifactsDir, "nested"), { recursive: true }); + await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); + await Bun.write(path.join(sourceArtifactsDir, "1.read.log"), "tool output"); + await Bun.write(path.join(sourceArtifactsDir, "nested", "result.txt"), "nested output"); + + const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { + suppressBreadcrumb: true, + }); + const forkFile = forked.getSessionFile(); + if (!forkFile) throw new Error("expected forked session file"); + const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); + + expect(await Bun.file(path.join(forkArtifactsDir, "1.read.log")).text()).toBe("tool output"); + expect(await Bun.file(path.join(forkArtifactsDir, "nested", "result.txt")).text()).toBe("nested output"); + expect(await Bun.file(path.join(sourceArtifactsDir, "1.read.log")).text()).toBe("tool output"); + }); }); From 62f59c86ebf709242c4b8e7438cd7751ac6c6556 Mon Sep 17 00:00:00 2001 From: Anatoli Tsinovoy Date: Sat, 15 Aug 2026 21:18:23 +0300 Subject: [PATCH 2/2] fix(coding-agent): guard fork artifact paths --- .../src/session/session-manager.ts | 19 +++-- .../test/session/session-manager-fork.test.ts | 85 +++++++++++++++---- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 05814a687..1ed1673c5 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -97,13 +97,15 @@ function fileSafeTimestamp(iso: string): string { } function artifactsDirectoryFor(sessionFile: string | undefined): string | null { - return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null; + if (!sessionFile?.endsWith(".jsonl")) return null; + return sessionFile.slice(0, -JSONL_SUFFIX_LENGTH); } /** Copy a session's artifact directory to another session, matching interactive `/fork`. */ export async function copySessionArtifacts(sourceSessionFile: string, destinationSessionFile: string): Promise { - const sourceArtifactsDir = artifactsDirectoryFor(sourceSessionFile)!; - const destinationArtifactsDir = artifactsDirectoryFor(destinationSessionFile)!; + const sourceArtifactsDir = artifactsDirectoryFor(sourceSessionFile); + const destinationArtifactsDir = artifactsDirectoryFor(destinationSessionFile); + if (!sourceArtifactsDir || !destinationArtifactsDir) return; if (path.resolve(sourceArtifactsDir) === path.resolve(destinationArtifactsDir)) return; try { @@ -1462,10 +1464,13 @@ export class SessionManager { const oldSessionFile = this.#sessionFile; const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile)); - const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!; - const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!; + const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile); + const newArtifactsDir = artifactsDirectoryFor(newSessionFile); const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile); - const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir); + const artifactPathChanged = + oldArtifactsDir !== null && + newArtifactsDir !== null && + path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir); sessionFileExisted = this.#storage.existsSync(oldSessionFile); let sessionMoved = false; @@ -1489,7 +1494,7 @@ export class SessionManager { } } } catch (err) { - if (artifactsMoved) { + if (artifactsMoved && oldArtifactsDir && newArtifactsDir) { try { await fs.promises.rename(newArtifactsDir, oldArtifactsDir); } catch (rollbackErr) { diff --git a/packages/coding-agent/test/session/session-manager-fork.test.ts b/packages/coding-agent/test/session/session-manager-fork.test.ts index 2e3193434..d3e78e4bf 100644 --- a/packages/coding-agent/test/session/session-manager-fork.test.ts +++ b/packages/coding-agent/test/session/session-manager-fork.test.ts @@ -23,6 +23,30 @@ interface JsonlMessageEntry { }; } +async function createSessionWithArtifacts(root: string): Promise<{ + cwd: string; + sessionDir: string; + sourceFile: string; + sourceArtifactsDir: string; +}> { + const cwd = path.join(root, "project"); + const sessionDir = path.join(root, "sessions"); + const sourceFile = path.join(sessionDir, "source.jsonl"); + const sourceArtifactsDir = sourceFile.slice(0, -".jsonl".length); + const sourceHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "source-with-artifacts", + timestamp: new Date().toISOString(), + cwd, + }; + await fs.mkdir(path.join(sourceArtifactsDir, "nested"), { recursive: true }); + await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); + await Bun.write(path.join(sourceArtifactsDir, "1.read.log"), "tool output"); + await Bun.write(path.join(sourceArtifactsDir, "nested", "result.txt"), "nested output"); + return { cwd, sessionDir, sourceFile, sourceArtifactsDir }; +} + describe("SessionManager.forkFrom", () => { it("suppresses terminal breadcrumbs while preserving source history under a new parented session", async () => { using tempDir = TempDir.createSync("@omp-session-fork-"); @@ -90,22 +114,7 @@ describe("SessionManager.forkFrom", () => { it("copies source artifacts recursively into the fork by default", async () => { using tempDir = TempDir.createSync("@omp-session-fork-artifacts-"); - const cwd = path.join(tempDir.path(), "project"); - const sessionDir = path.join(tempDir.path(), "sessions"); - const sourceFile = path.join(sessionDir, "source.jsonl"); - const sourceArtifactsDir = sourceFile.slice(0, -".jsonl".length); - const timestamp = new Date().toISOString(); - const sourceHeader: SessionHeader = { - type: "session", - version: CURRENT_SESSION_VERSION, - id: "source-with-artifacts", - timestamp, - cwd, - }; - await fs.mkdir(path.join(sourceArtifactsDir, "nested"), { recursive: true }); - await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); - await Bun.write(path.join(sourceArtifactsDir, "1.read.log"), "tool output"); - await Bun.write(path.join(sourceArtifactsDir, "nested", "result.txt"), "nested output"); + const { cwd, sessionDir, sourceFile, sourceArtifactsDir } = await createSessionWithArtifacts(tempDir.path()); const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { suppressBreadcrumb: true, @@ -118,4 +127,48 @@ describe("SessionManager.forkFrom", () => { expect(await Bun.file(path.join(forkArtifactsDir, "nested", "result.txt")).text()).toBe("nested output"); expect(await Bun.file(path.join(sourceArtifactsDir, "1.read.log")).text()).toBe("tool output"); }); + + it("does not copy artifacts when the caller opts out", async () => { + using tempDir = TempDir.createSync("@omp-session-fork-no-artifacts-"); + const { cwd, sessionDir, sourceFile } = await createSessionWithArtifacts(tempDir.path()); + + const forked = await SessionManager.forkFrom(sourceFile, cwd, sessionDir, undefined, { + copyArtifacts: false, + suppressBreadcrumb: true, + }); + const forkFile = forked.getSessionFile(); + if (!forkFile) throw new Error("expected forked session file"); + const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); + + expect(await Bun.file(path.join(forkArtifactsDir, "1.read.log")).exists()).toBe(false); + }); + + it("does not treat an extensionless source's parent directory as artifacts", async () => { + using tempDir = TempDir.createSync("@omp-session-fork-extensionless-"); + const cwd = path.join(tempDir.path(), "project"); + const sessionDir = path.join(tempDir.path(), "sessions"); + const forkDir = path.join(tempDir.path(), "forks"); + const sourceFile = path.join(sessionDir, "source"); + const unrelatedFile = path.join(sessionDir, "unrelated.txt"); + const sourceHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: "extensionless-source", + timestamp: new Date().toISOString(), + cwd, + }; + await fs.mkdir(sessionDir, { recursive: true }); + await Bun.write(sourceFile, `${JSON.stringify(sourceHeader)}\n`); + await Bun.write(unrelatedFile, "must not be copied"); + + const forked = await SessionManager.forkFrom(sourceFile, cwd, forkDir, undefined, { + suppressBreadcrumb: true, + }); + const forkFile = forked.getSessionFile(); + if (!forkFile) throw new Error("expected forked session file"); + const forkArtifactsDir = forkFile.slice(0, -".jsonl".length); + + expect(await Bun.file(path.join(forkArtifactsDir, "unrelated.txt")).exists()).toBe(false); + expect(await Bun.file(unrelatedFile).text()).toBe("must not be copied"); + }); });