From d933cfbe05f6a0a05b1aad77c695e705509a1586 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 29 Jul 2026 06:14:06 +0000 Subject: [PATCH 1/4] fix(coding-agent): shared parent local root with tan clone TanCommandController.start nests the tan clone at /Tan-.jsonl, so the clone's session manager derived its own artifacts dir and hence local root /Tan-/local. Its sdk.createAgentSession call omitted localProtocolOptions, unlike the task-subagent path which inherits the parent's mapping, so parent-session '/data/workspaces/can1357__oh-my-pi__6971/.omp-session/2026-07-29T06-08-45-283Z_019fac7d-5ee3-7000-a7aa-16fe9394fdc9/local' attachments (pasted files, generated references) were unreadable. Thread the parent session manager's localProtocolOptions into the tan clone so local:// resolves against /local. Fixes #6971 (cherry picked from commit 1ded46e182fc24f9f57d8e9a907aaad58f790783) --- packages/coding-agent/CHANGELOG.md | 4 +++ .../controllers/tan-command-controller.ts | 12 ++++++++ .../tan-command-controller.test.ts | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d97a1f633..0d5a81dfb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/tan` agents being unable to read parent-session `local://` attachments (pasted files, generated references). `TanCommandController` now threads the parent session's `localProtocolOptions` into the tan clone, so `local://` resolves against `/local` instead of the clone-nested `/Tan-/local` root — matching the task-subagent path ([#6971](https://github.com/can1357/oh-my-pi/issues/6971)). + ## [17.1.8] - 2026-07-28 ### Breaking Changes 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 2be9fe24c..8faf23be1 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -80,6 +80,17 @@ export class TanCommandController { const ownerId = session.getAgentId() ?? MAIN_AGENT_ID; const mcpManager = this.ctx.mcpManager; const cwd = this.ctx.sessionManager.getCwd(); + const parentSessionManager = this.ctx.sessionManager; + // Share the parent session's local:// root so the tan can read attachments + // (pasted files, generated references) the parent stored under + // `/local`. The clone's own session file nests one level + // deeper (`/Tan-.jsonl`), so without this its derived + // root would be `/Tan-/local` — matching the + // task-subagent path, which explicitly inherits the parent's mapping. + const localProtocolOptions = { + getArtifactsDir: () => parentSessionManager.getArtifactsDir(), + getSessionId: () => parentSessionManager.getSessionId(), + }; // Nest the clone inside the parent's artifact directory (like a subagent // session) rather than as a top-level sibling, so it shares the parent's // artifacts in place — no copy needed. @@ -132,6 +143,7 @@ export class TanCommandController { parentAgentId: ownerId, agentRegistry, disableExtensionDiscovery: true, + localProtocolOptions, }); clone = created.session; clone.sessionManager?.appendSessionInit?.({ 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 d8a120c43..271f9ec2d 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 @@ -3,6 +3,7 @@ import * as path from "node:path"; import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai"; import type { AsyncJobRegisterOptions } from "@oh-my-pi/pi-coding-agent/async/job-manager"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resolveLocalRoot } from "@oh-my-pi/pi-coding-agent/internal-urls/local-protocol"; import { TanCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/tan-command-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import { AgentRegistry, MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; @@ -118,10 +119,13 @@ function createContext(overrides?: { sequence.push("sendCustomMessage"); }), } as unknown as InteractiveModeContext["session"]; + const parentArtifactsDir = parentFile.slice(0, -6); const sessionManager = { getSessionFile: vi.fn(() => parentFile), getCwd: vi.fn(() => tempDir.path()), getSessionDir: vi.fn(() => tempDir.path()), + getArtifactsDir: vi.fn(() => parentArtifactsDir), + getSessionId: vi.fn(() => "parent-session"), ensureOnDisk: vi.fn(async () => {}), flush: vi.fn(async () => {}), } as unknown as InteractiveModeContext["sessionManager"]; @@ -141,6 +145,7 @@ function createContext(overrides?: { return { tempDir, parentFile, + parentArtifactsDir, cloneFile, cloneManager, ctx, @@ -225,6 +230,31 @@ describe("TanCommandController", () => { expect(harness.ctx.showStatus).toHaveBeenCalledWith("Dispatched background tan job-123"); }); + it("shares the parent session's local:// root with the tan clone", async () => { + const harness = createContext(); + vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); + const { clone } = createCloneStub({ lastAssistantText: "done" }); + let capturedOptions: Parameters[0] | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + capturedOptions = options; + return { session: clone } as unknown as CreateAgentSessionResult; + }); + const controller = new TanCommandController(harness.ctx); + + await controller.start("read local://paste-1.md"); + const capturedRun = harness.capturedRun; + if (!capturedRun) throw new Error("run function was not captured"); + await capturedRun({ jobId: "job-123", signal: new AbortController().signal, reportProgress: async () => {} }); + + expect(capturedOptions?.localProtocolOptions).toBeDefined(); + const opts = capturedOptions?.localProtocolOptions; + if (!opts) throw new Error("localProtocolOptions was not passed"); + // The clone must resolve local:// against the parent's local root, not the + // clone-nested `/Tan-/local` it would derive on its own. + expect(resolveLocalRoot(opts)).toBe(path.join(harness.parentArtifactsDir, "local")); + expect(opts.getSessionId?.()).toBe("parent-session"); + }); + it("aborts the cloned agent when the background job signal aborts", async () => { const harness = createContext({ agentId: MAIN_AGENT_ID }); vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); From fb4393ae16e18c1140c763413faffd0fb6d1be5a Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 29 Jul 2026 06:21:42 +0000 Subject: [PATCH 2/4] fix(coding-agent): snapshotted tan parent local root Capture the parent artifacts directory and session ID when /tan dispatches instead of resolving them through the mutable interactive SessionManager. This keeps background tan '/data/workspaces/can1357__oh-my-pi__6971/.omp-session/2026-07-29T06-08-45-283Z_019fac7d-5ee3-7000-a7aa-16fe9394fdc9/local' reads pinned to the dispatching transcript after the user switches or resumes another session. Extend the regression test to switch the mocked interactive session before the background job starts and assert the original local mapping is retained. Fixes #6971 (cherry picked from commit e05db428eabd5087e4b2b4a462f47927c0628e72) --- .../controllers/tan-command-controller.ts | 15 ++++++--------- .../tan-command-controller.test.ts | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) 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 8faf23be1..285122f7b 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -80,16 +80,13 @@ export class TanCommandController { const ownerId = session.getAgentId() ?? MAIN_AGENT_ID; const mcpManager = this.ctx.mcpManager; const cwd = this.ctx.sessionManager.getCwd(); - const parentSessionManager = this.ctx.sessionManager; - // Share the parent session's local:// root so the tan can read attachments - // (pasted files, generated references) the parent stored under - // `/local`. The clone's own session file nests one level - // deeper (`/Tan-.jsonl`), so without this its derived - // root would be `/Tan-/local` — matching the - // task-subagent path, which explicitly inherits the parent's mapping. + const parentArtifactsDir = this.ctx.sessionManager.getArtifactsDir(); + // Snapshot the parent session's local:// mapping when dispatching. The + // interactive SessionManager is mutable and may switch transcripts while + // this background tan is still running. const localProtocolOptions = { - getArtifactsDir: () => parentSessionManager.getArtifactsDir(), - getSessionId: () => parentSessionManager.getSessionId(), + getArtifactsDir: () => parentArtifactsDir, + getSessionId: () => parentSessionId, }; // Nest the clone inside the parent's artifact directory (like a subagent // session) rather than as a top-level sibling, so it shares the parent's 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 271f9ec2d..b4f1e3e4d 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 @@ -7,7 +7,7 @@ import { resolveLocalRoot } from "@oh-my-pi/pi-coding-agent/internal-urls/local- import { TanCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/tan-command-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import { AgentRegistry, MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; -import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; +import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; @@ -120,12 +120,14 @@ function createContext(overrides?: { }), } as unknown as InteractiveModeContext["session"]; const parentArtifactsDir = parentFile.slice(0, -6); + const getArtifactsDir = vi.fn(() => parentArtifactsDir); + const getSessionId = vi.fn(() => "parent-session"); const sessionManager = { getSessionFile: vi.fn(() => parentFile), getCwd: vi.fn(() => tempDir.path()), getSessionDir: vi.fn(() => tempDir.path()), - getArtifactsDir: vi.fn(() => parentArtifactsDir), - getSessionId: vi.fn(() => "parent-session"), + getArtifactsDir, + getSessionId, ensureOnDisk: vi.fn(async () => {}), flush: vi.fn(async () => {}), } as unknown as InteractiveModeContext["sessionManager"]; @@ -149,6 +151,8 @@ function createContext(overrides?: { cloneFile, cloneManager, ctx, + getArtifactsDir, + getSessionId, register, sequence, get capturedRun() { @@ -230,11 +234,11 @@ describe("TanCommandController", () => { expect(harness.ctx.showStatus).toHaveBeenCalledWith("Dispatched background tan job-123"); }); - it("shares the parent session's local:// root with the tan clone", async () => { + it("keeps the dispatching session's local:// root after the interactive session switches", async () => { const harness = createContext(); vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); const { clone } = createCloneStub({ lastAssistantText: "done" }); - let capturedOptions: Parameters[0] | undefined; + let capturedOptions: CreateAgentSessionOptions | undefined; vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { capturedOptions = options; return { session: clone } as unknown as CreateAgentSessionResult; @@ -242,15 +246,14 @@ describe("TanCommandController", () => { const controller = new TanCommandController(harness.ctx); await controller.start("read local://paste-1.md"); + harness.getArtifactsDir.mockReturnValue(path.join(harness.tempDir.path(), "other-session")); + harness.getSessionId.mockReturnValue("other-session"); const capturedRun = harness.capturedRun; if (!capturedRun) throw new Error("run function was not captured"); await capturedRun({ jobId: "job-123", signal: new AbortController().signal, reportProgress: async () => {} }); - expect(capturedOptions?.localProtocolOptions).toBeDefined(); const opts = capturedOptions?.localProtocolOptions; if (!opts) throw new Error("localProtocolOptions was not passed"); - // The clone must resolve local:// against the parent's local root, not the - // clone-nested `/Tan-/local` it would derive on its own. expect(resolveLocalRoot(opts)).toBe(path.join(harness.parentArtifactsDir, "local")); expect(opts.getSessionId?.()).toBe("parent-session"); }); From dd4985f31e424a3bf12e6d19368fcbaeeea7c28e Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 29 Jul 2026 06:29:25 +0000 Subject: [PATCH 3/4] fix(coding-agent): scoped subagent local root overrides Keep subagent localProtocolOptions on their ToolSession instead of installing them as the process-global LocalProtocolHandler override. No-context URL consumers therefore retain the active top-level session's mapping while tan and task subagents continue to resolve through their caller context. Add SDK regression coverage proving subagent creation preserves an existing global mapping. Fixes #6971 (cherry picked from commit a02eef174b03036dc960c842a0901d22333ad9cd) --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/internal-urls/local-protocol.ts | 5 ++- packages/coding-agent/src/sdk.ts | 2 +- .../test/sdk-session-isolation.test.ts | 45 +++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0d5a81dfb..e267b6085 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed `/tan` agents being unable to read parent-session `local://` attachments (pasted files, generated references). `TanCommandController` now threads the parent session's `localProtocolOptions` into the tan clone, so `local://` resolves against `/local` instead of the clone-nested `/Tan-/local` root — matching the task-subagent path ([#6971](https://github.com/can1357/oh-my-pi/issues/6971)). +- Fixed `/tan` agents being unable to read parent-session `local://` attachments (pasted files, generated references). `TanCommandController` now threads the parent session's `localProtocolOptions` into the tan clone, so `local://` resolves against `/local` instead of the clone-nested `/Tan-/local` root. Subagent local mappings now remain session-bound rather than replacing the process-global override used by active-session suggestions and links ([#6971](https://github.com/can1357/oh-my-pi/issues/6971)). ## [17.1.8] - 2026-07-28 diff --git a/packages/coding-agent/src/internal-urls/local-protocol.ts b/packages/coding-agent/src/internal-urls/local-protocol.ts index 27a1bd752..a20724390 100644 --- a/packages/coding-agent/src/internal-urls/local-protocol.ts +++ b/packages/coding-agent/src/internal-urls/local-protocol.ts @@ -390,8 +390,9 @@ export class LocalProtocolHandler implements ProtocolHandler { /** * Install a process-global override that wins over the AgentRegistry-based - * derivation. Used by SDK consumers that wire `localProtocolOptions` on - * `createAgentSession` and by subagents that share their parent's root. + * derivation. Used by top-level SDK consumers that wire + * `localProtocolOptions` on `createAgentSession`; subagents keep their + * inherited mapping session-bound. */ static setOverride(value: LocalProtocolOptions | undefined): void { LocalProtocolHandler.#override = value; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index afe318051..efcd398b8 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1772,7 +1772,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} getArtifactsDir, getSessionId: () => sessionManager.getSessionId?.() ?? null, }; - if (options.localProtocolOptions) { + if (options.localProtocolOptions && !options.parentTaskPrefix) { LocalProtocolHandler.setOverride(options.localProtocolOptions); } toolSession.getArtifactsDir = getArtifactsDir; diff --git a/packages/coding-agent/test/sdk-session-isolation.test.ts b/packages/coding-agent/test/sdk-session-isolation.test.ts index 832dfff5a..2850af256 100644 --- a/packages/coding-agent/test/sdk-session-isolation.test.ts +++ b/packages/coding-agent/test/sdk-session-isolation.test.ts @@ -7,6 +7,7 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { LocalProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/local-protocol"; import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; @@ -109,6 +110,7 @@ describe("createAgentSession session storage isolation", () => { afterEach(async () => { vi.restoreAllMocks(); + LocalProtocolHandler.resetOverrideForTests(); for (const tempDir of tempDirs.splice(0)) { removeSyncWithRetries(tempDir); } @@ -147,6 +149,49 @@ describe("createAgentSession session storage isolation", () => { await session.dispose(); } }); + it("keeps subagent local:// mappings from replacing the process-global override", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-local-override-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwd = path.join(tempDir, "project"); + fs.mkdirSync(cwd, { recursive: true }); + const globalOptions = { + getArtifactsDir: () => path.join(tempDir, "active-artifacts"), + getSessionId: () => "active-session", + }; + const subagentOptions = { + getArtifactsDir: () => path.join(tempDir, "parent-artifacts"), + getSessionId: () => "parent-session", + }; + LocalProtocolHandler.setOverride(globalOptions); + + const { session } = await createAgentSession({ + cwd, + agentDir: path.join(tempDir, "agent"), + modelRegistry: sharedModelRegistry, + settings: Settings.isolated(), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + toolNames: [], + enableMCP: false, + enableLsp: false, + agentRegistry: new AgentRegistry(), + agentId: "Tan-local-override-test", + agentDisplayName: "tan", + parentTaskPrefix: "Tan-local-override-test", + parentAgentId: "Main", + localProtocolOptions: subagentOptions, + }); + + try { + expect(LocalProtocolHandler.resolveOptions()).toBe(globalOptions); + } finally { + await session.dispose(); + } + }); + it("does not replace a newer registry generation when creation expected the id to be absent", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-sdk-generation-cas-${Snowflake.next()}-`)); tempDirs.push(tempDir); From bbf3d78c7efcd7a06a45f9c1d471850738c2b4bd Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 29 Jul 2026 06:35:41 +0000 Subject: [PATCH 4/4] fix(coding-agent): keyed tan local root on session-manager id Snapshot this.ctx.sessionManager.getSessionId() for the tan clone's local:// mapping instead of session.sessionId. The two diverge after /fresh or a provider session override, and the Windows short-root fallback keys %TEMP%/omp-local/ off the session-manager id used by the parent's large-paste writes and '/data/workspaces/can1357__oh-my-pi__6971/.omp-session/2026-07-29T06-08-45-283Z_019fac7d-5ee3-7000-a7aa-16fe9394fdc9/local' reads, so the mismatched id left attachments unreachable. Diverge the mocked session id from the manager id in the regression test so it pins the session-manager id. Fixes #6971 (cherry picked from commit 1efcd22326d76fdb8b50c0977a836c892e80ab76) --- .../src/modes/controllers/tan-command-controller.ts | 9 +++++++-- .../modes/controllers/tan-command-controller.test.ts | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) 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 285122f7b..4e9ea758f 100644 --- a/packages/coding-agent/src/modes/controllers/tan-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts @@ -83,10 +83,15 @@ export class TanCommandController { const parentArtifactsDir = this.ctx.sessionManager.getArtifactsDir(); // Snapshot the parent session's local:// mapping when dispatching. The // interactive SessionManager is mutable and may switch transcripts while - // this background tan is still running. + // this background tan is still running. Use the session-manager id (not + // `session.sessionId`, which can diverge after `/fresh` or a provider + // session override) so the tan resolves the same local root the parent's + // large-paste writes and `local://` reads use — notably the Windows + // short-root fallback keys `%TEMP%/omp-local/` off this id. + const parentLocalSessionId = this.ctx.sessionManager.getSessionId(); const localProtocolOptions = { getArtifactsDir: () => parentArtifactsDir, - getSessionId: () => parentSessionId, + getSessionId: () => parentLocalSessionId, }; // Nest the clone inside the parent's artifact directory (like a subagent // session) rather than as a top-level sibling, so it shares the parent's 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 b4f1e3e4d..0f2e4cc8a 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 @@ -121,7 +121,7 @@ function createContext(overrides?: { } as unknown as InteractiveModeContext["session"]; const parentArtifactsDir = parentFile.slice(0, -6); const getArtifactsDir = vi.fn(() => parentArtifactsDir); - const getSessionId = vi.fn(() => "parent-session"); + const getSessionId = vi.fn(() => "parent-local-session"); const sessionManager = { getSessionFile: vi.fn(() => parentFile), getCwd: vi.fn(() => tempDir.path()), @@ -255,7 +255,9 @@ describe("TanCommandController", () => { const opts = capturedOptions?.localProtocolOptions; if (!opts) throw new Error("localProtocolOptions was not passed"); expect(resolveLocalRoot(opts)).toBe(path.join(harness.parentArtifactsDir, "local")); - expect(opts.getSessionId?.()).toBe("parent-session"); + // The local mapping keys off the session-manager id (not `session.sessionId`, + // still "parent-session"), matching the parent's large-paste / local:// writes. + expect(opts.getSessionId?.()).toBe("parent-local-session"); }); it("aborts the cloned agent when the background job signal aborts", async () => {