diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 80a1dc718..0ad61ea37 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ ### Changed - Improved grouped read-call layout by nesting each request's usage metrics beneath its final path ([#6946](https://github.com/can1357/oh-my-pi/pull/6946) by [@joshrzemien](https://github.com/joshrzemien)). +- 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/modes/controllers/tan-command-controller.ts b/packages/coding-agent/src/modes/controllers/tan-command-controller.ts index 2be9fe24c..4e9ea758f 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,19 @@ export class TanCommandController { const ownerId = session.getAgentId() ?? MAIN_AGENT_ID; const mcpManager = this.ctx.mcpManager; const cwd = this.ctx.sessionManager.getCwd(); + 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. 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: () => 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 // artifacts in place — no copy needed. @@ -132,6 +145,7 @@ export class TanCommandController { parentAgentId: ownerId, agentRegistry, disableExtensionDiscovery: true, + localProtocolOptions, }); clone = created.session; clone.sessionManager?.appendSessionInit?.({ diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 9778466bd..95ad3999e 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1773,7 +1773,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/modes/controllers/tan-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/tan-command-controller.test.ts index d8a120c43..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 @@ -3,10 +3,11 @@ 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"; -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"; @@ -118,10 +119,15 @@ function createContext(overrides?: { sequence.push("sendCustomMessage"); }), } as unknown as InteractiveModeContext["session"]; + const parentArtifactsDir = parentFile.slice(0, -6); + const getArtifactsDir = vi.fn(() => parentArtifactsDir); + const getSessionId = vi.fn(() => "parent-local-session"); const sessionManager = { getSessionFile: vi.fn(() => parentFile), getCwd: vi.fn(() => tempDir.path()), getSessionDir: vi.fn(() => tempDir.path()), + getArtifactsDir, + getSessionId, ensureOnDisk: vi.fn(async () => {}), flush: vi.fn(async () => {}), } as unknown as InteractiveModeContext["sessionManager"]; @@ -141,9 +147,12 @@ function createContext(overrides?: { return { tempDir, parentFile, + parentArtifactsDir, cloneFile, cloneManager, ctx, + getArtifactsDir, + getSessionId, register, sequence, get capturedRun() { @@ -225,6 +234,32 @@ describe("TanCommandController", () => { expect(harness.ctx.showStatus).toHaveBeenCalledWith("Dispatched background tan job-123"); }); + 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: CreateAgentSessionOptions | 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"); + 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 () => {} }); + + const opts = capturedOptions?.localProtocolOptions; + if (!opts) throw new Error("localProtocolOptions was not passed"); + expect(resolveLocalRoot(opts)).toBe(path.join(harness.parentArtifactsDir, "local")); + // 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 () => { const harness = createContext({ agentId: MAIN_AGENT_ID }); vi.spyOn(SessionManager, "forkFrom").mockResolvedValue(harness.cloneManager); 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);