Merge PR #6973: fix(coding-agent): share parent local:// root with /tan clone (@roboomp)

This commit is contained in:
can1357
2026-07-29 23:08:44 +02:00
6 changed files with 100 additions and 4 deletions
+1
View File
@@ -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 `<parent-artifacts>/local` instead of the clone-nested `<parent-artifacts>/Tan-<id>/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
@@ -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;
@@ -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/<id>` 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?.({
+1 -1
View File
@@ -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;
@@ -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);
@@ -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);