From ac67548bc1c47873c1b79e5536492b6201a366c4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 28 Jul 2026 16:42:04 +0000 Subject: [PATCH 1/4] fix(session): gated xd:// mount notices against announced history Mount-notice injection diff-gated only against the in-memory mountedNames set, which is reseeded on every process resume / host reconnect. Dynamic devices (MCP / RPC host) already announced in persisted history therefore re-announced, splicing a redundant developer message that busts the provider prompt-cache prefix and re-bills the whole suffix at full price on metered providers. Notices now persist a structured { added, removed } payload. On the first consumption after resume the announced-device baseline is reconstructed from history, and only a net change relative to what the model already knows is announced, so a resume re-establishing the same inventory emits nothing. Fixes #6921 (cherry picked from commit 03c2ed5189510f41431bd164fa80187a69ed8de9) --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/session-tools.ts | 58 +++++++++++++++++-- .../agent-session-tool-rebuild-skip.test.ts | 53 ++++++++++++++++- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d97a1f633..3acedc147 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `xd://` mount notices re-announcing already-known devices on session resume / host reconnect: the notice was diff-gated only against the in-memory mount set, which reset each resume, so reconnecting MCP/RPC-host devices re-spliced a redundant developer message into history and busted the provider prompt-cache prefix (re-billing the whole suffix at full price on metered providers). Notices now carry a structured `{ added, removed }` payload and are gated against the devices persisted history already announced, so a resume that re-establishes the same inventory emits nothing ([#6921](https://github.com/can1357/oh-my-pi/issues/6921)). + ## [17.1.8] - 2026-07-28 ### Breaking Changes diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index b29c51bc0..1c4974bf8 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -159,6 +159,18 @@ export function projectMountedMCPXdevGuidance(routes: Iterable(); #xdev: XdevState | undefined; #pendingXdevMountDelta: { added: Set; removed: Set } | undefined; + /** + * Dynamic (`xd://`) devices the model has already been told are mounted. + * Seeded lazily from persisted history on resume (see + * {@link #ensureAnnouncedMountsSeeded}) and updated as notices are emitted, so + * a host reconnect that re-mounts the same device does not re-announce it. + */ + #announcedMounts = new Set(); + #announcedMountsSeeded = false; #presentationPinnedToolNames: ReadonlySet | undefined; #runtimeSelectedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; @@ -674,26 +694,56 @@ export class SessionTools { this.#host.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev"); } + /** + * Seed {@link #announcedMounts} from persisted mount notices the first time a + * notice is consumed. On resume the in-memory mount set is rebuilt from + * scratch, so without replaying history every already-announced dynamic device + * would look freshly mounted and re-announce. + */ + #ensureAnnouncedMountsSeeded(): void { + if (this.#announcedMountsSeeded) return; + this.#announcedMountsSeeded = true; + for (const message of this.#host.agent.state.messages) { + if (message.role !== "custom" || message.customType !== XDEV_MOUNT_NOTICE_MESSAGE_TYPE) continue; + const details = message.details as XdevMountNoticeDetails | undefined; + if (!details) continue; + for (const name of details.added ?? []) this.#announcedMounts.add(name); + for (const name of details.removed ?? []) this.#announcedMounts.delete(name); + } + } + /** Consumes the hidden notice for unannounced `xd://` mount changes. */ - takePendingXdevMountNotice(): CustomMessage | undefined { + takePendingXdevMountNotice(): CustomMessage | undefined { const pending = this.#pendingXdevMountDelta; if (!pending) return undefined; this.#pendingXdevMountDelta = undefined; + this.#ensureAnnouncedMountsSeeded(); + // Only announce a net change relative to what the model already knows (from + // this session and persisted history): a re-mount of an already-announced + // device — the common resume/reconnect case — and an unmount for a device + // it was never told about are both suppressed, keeping the provider prompt + // cache prefix byte-stable across resumes. + const addedNames = [...pending.added].filter(name => !this.#announcedMounts.has(name)); + const removedNames = [...pending.removed].filter(name => this.#announcedMounts.has(name)); + if (addedNames.length === 0 && removedNames.length === 0) return undefined; const summaries = new Map(this.#xdev ? xdevEntries(this.#xdev).map(entry => [entry.name, entry.summary]) : []); - const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" })); - const removed = [...pending.removed].map(name => ({ name })); + const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" })); + const removed = removedNames.map(name => ({ name })); const docs = this.#xdev ? xdevDocsFor( this.#xdev, - pending.added, + new Set(addedNames), this.#host.settings.get("tools.xdevDocs"), this.#host.settings.get("tools.xdevInlineDevices"), ) : ""; + for (const name of addedNames) this.#announcedMounts.add(name); + for (const name of removedNames) this.#announcedMounts.delete(name); return { role: "custom", customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE, content: prompt.render(xdevMountNoticePrompt, { added, removed, docs }), + details: { added: addedNames, removed: removedNames }, attribution: "agent", display: false, timestamp: Date.now(), diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index ff09072c0..c82931cb7 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Message, Model } from "@oh-my-pi/pi-ai"; import { createMockModel, type MockResponseSource } from "@oh-my-pi/pi-ai/providers/mock"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; +import { type CustomMessage, convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { collectMountedMCPToolRoutes, @@ -102,6 +102,8 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { lazyWrite?: boolean; /** Scripted mock model responses; enables driving `session.prompt()`. */ responses?: MockResponseSource; + /** Persisted history seeded into the agent, e.g. to model a resumed session. */ + initialMessages?: AgentMessage[]; } function newSession( @@ -133,7 +135,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { ? [readTool, initialMcp as unknown as AgentTool] : [readTool, writeTool, initialMcp as unknown as AgentTool] : [readTool, initialMcp as unknown as AgentTool], - messages: [], + messages: options.initialMessages ?? [], }, convertToLlm, streamFn: mock @@ -926,6 +928,51 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { expect(notices[0]).not.toContain("No longer mounted"); }); + it("does not re-announce devices a resumed session already announced in history", async () => { + // Model a process resume / host reconnect: persisted history already carries + // a mount notice for mcp__nucleus_search, but the fresh in-memory mount set + // starts empty. When the device reconnects, the notice must NOT re-splice a + // redundant developer message — doing so busts the provider prompt-cache + // prefix and re-bills the whole suffix on metered providers. + const priorNotice: AgentMessage = { + role: "custom", + customType: "xdev-mount-notice", + content: "The xd:// device inventory changed.\n\nxd://mcp__nucleus_search became available.", + details: { added: ["mcp__nucleus_search"], removed: [] }, + attribution: "agent", + display: false, + timestamp: Date.now() - 1000, + }; + const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { + xdev: createTestXdevState(), + responses: [{ content: ["ok"] }, { content: ["ok"] }], + initialMessages: [priorNotice], + }); + const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus"); + const fetch = createMcpCustomTool("mcp__nucleus_fetch", "nucleus", "fetch", "Fetch nucleus"); + + // The already-announced device reconnects: no new notice is spliced in. + await session.refreshMCPTools([search]); + await session.prompt("hello"); + const afterReconnect = session.agent.state.messages.filter( + message => message.role === "custom" && message.customType === "xdev-mount-notice", + ); + expect(afterReconnect).toHaveLength(1); + expect(mountNoticesIn(contexts[0])).toHaveLength(1); // only the pre-existing history notice + + // A genuinely new device still announces, and only for itself. + await session.refreshMCPTools([search, fetch]); + await session.prompt("again"); + const afterNewDevice = session.agent.state.messages.filter( + (message): message is CustomMessage => message.role === "custom" && message.customType === "xdev-mount-notice", + ); + expect(afterNewDevice).toHaveLength(2); + const fetchNotice = afterNewDevice[1]; + const fetchText = typeof fetchNotice.content === "string" ? fetchNotice.content : ""; + expect(fetchText).toContain("xd://mcp__nucleus_fetch"); + expect(fetchText).not.toContain("xd://mcp__nucleus_search"); + }); + it("keeps xd:// mount deltas model-visible without rendering them during quiet startup", async () => { const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { xdev: createTestXdevState(), From 82dc0d43ae8d0d7c81c92102635c56c5b40c72a8 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 28 Jul 2026 16:50:49 +0000 Subject: [PATCH 2/4] fix(session): migrated legacy xd mount notices on resume Replayed the stable added and removed inventory sections from legacy xdev-mount-notice content when structured details are absent. This keeps the first post-upgrade resume from re-announcing devices that persisted history already introduced. Covered both structured and legacy resume histories, including removed devices and inline docs that must not be interpreted as inventory. Fixes #6921 (cherry picked from commit 634a4c2de75f99e219f408c56bed83c04fe1290a) --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/session/session-tools.ts | 45 ++++++++++++++++--- .../agent-session-tool-rebuild-skip.test.ts | 44 +++++++++++++----- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3acedc147..525dbdeb5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed `xd://` mount notices re-announcing already-known devices on session resume / host reconnect: the notice was diff-gated only against the in-memory mount set, which reset each resume, so reconnecting MCP/RPC-host devices re-spliced a redundant developer message into history and busted the provider prompt-cache prefix (re-billing the whole suffix at full price on metered providers). Notices now carry a structured `{ added, removed }` payload and are gated against the devices persisted history already announced, so a resume that re-establishes the same inventory emits nothing ([#6921](https://github.com/can1357/oh-my-pi/issues/6921)). +- Fixed `xd://` mount notices re-announcing already-known devices on session resume / host reconnect: the notice was diff-gated only against the in-memory mount set, which reset each resume, so reconnecting MCP/RPC-host devices re-spliced a redundant developer message into history and busted the provider prompt-cache prefix (re-billing the whole suffix at full price on metered providers). Notices now carry a structured `{ added, removed }` payload and are gated against the devices persisted history already announced—including legacy rendered notices from before the structured payload—so a resume that re-establishes the same inventory emits nothing ([#6921](https://github.com/can1357/oh-my-pi/issues/6921)). ## [17.1.8] - 2026-07-28 diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index 1c4974bf8..7be29d5c8 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -1,6 +1,6 @@ import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; -import { logger, prompt, stringProperty } from "@oh-my-pi/pi-utils"; +import { isRecord, logger, prompt, stringProperty } from "@oh-my-pi/pi-utils"; import { reset as resetCapabilities } from "../capability"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelString } from "../config/model-resolver"; @@ -705,10 +705,45 @@ export class SessionTools { this.#announcedMountsSeeded = true; for (const message of this.#host.agent.state.messages) { if (message.role !== "custom" || message.customType !== XDEV_MOUNT_NOTICE_MESSAGE_TYPE) continue; - const details = message.details as XdevMountNoticeDetails | undefined; - if (!details) continue; - for (const name of details.added ?? []) this.#announcedMounts.add(name); - for (const name of details.removed ?? []) this.#announcedMounts.delete(name); + const details = message.details; + if ( + isRecord(details) && + Array.isArray(details.added) && + details.added.every(name => typeof name === "string") && + Array.isArray(details.removed) && + details.removed.every(name => typeof name === "string") + ) { + for (const name of details.added) this.#announcedMounts.add(name); + for (const name of details.removed) this.#announcedMounts.delete(name); + continue; + } + + // Releases before structured notice details persisted only the rendered + // prompt. Replay its two stable inventory sections so the first resume + // after upgrading does not re-announce every dynamic device once. + if (typeof message.content !== "string") continue; + let section: "added" | "removed" | undefined; + for (const line of message.content.split("\n")) { + if (line === "These tools became available:") { + section = "added"; + continue; + } + if (line.startsWith("No longer mounted")) { + section = "removed"; + continue; + } + if (line === "Configured inline device docs:" || line === "") break; + if (line.startsWith("Read `xd://`")) { + section = undefined; + continue; + } + if (!section) continue; + const match = /^- xd:\/\/(\S+?)(?:\s+—|$)/.exec(line); + const name = match?.[1]; + if (!name) continue; + if (section === "added") this.#announcedMounts.add(name); + else this.#announcedMounts.delete(name); + } } } diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index c82931cb7..ff8414f1a 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -928,21 +928,45 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { expect(notices[0]).not.toContain("No longer mounted"); }); - it("does not re-announce devices a resumed session already announced in history", async () => { + it.each([ + { + priorNotice: { + role: "custom", + customType: "xdev-mount-notice", + content: "The xd:// device inventory changed.\n\nxd://mcp__nucleus_search became available.", + details: { added: ["mcp__nucleus_search"], removed: [] }, + attribution: "agent", + display: false, + timestamp: 1, + } satisfies AgentMessage, + }, + { + priorNotice: { + role: "custom", + customType: "xdev-mount-notice", + content: ` +The xd:// device inventory changed. +These tools became available: +- xd://mcp__nucleus_search — Search nucleus +- xd://mcp__retired — Retired device +Read \`xd://\` for docs + JSON schema before first use; write the JSON args object to \`xd://\` to execute. +No longer mounted (writes to these devices will fail): +- xd://mcp__retired +Configured inline device docs: +These tools became available: +- xd://mcp__nucleus_fetch — This is inline documentation, not an inventory entry. +`, + attribution: "agent", + display: false, + timestamp: 1, + } satisfies AgentMessage, + }, + ])("does not re-announce devices a resumed session already announced in history", async ({ priorNotice }) => { // Model a process resume / host reconnect: persisted history already carries // a mount notice for mcp__nucleus_search, but the fresh in-memory mount set // starts empty. When the device reconnects, the notice must NOT re-splice a // redundant developer message — doing so busts the provider prompt-cache // prefix and re-bills the whole suffix on metered providers. - const priorNotice: AgentMessage = { - role: "custom", - customType: "xdev-mount-notice", - content: "The xd:// device inventory changed.\n\nxd://mcp__nucleus_search became available.", - details: { added: ["mcp__nucleus_search"], removed: [] }, - attribution: "agent", - display: false, - timestamp: Date.now() - 1000, - }; const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { xdev: createTestXdevState(), responses: [{ content: ["ok"] }, { content: ["ok"] }], From 13b1077d3e4462b52aa7cf25a9d9b7069f633b2b Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 28 Jul 2026 17:03:01 +0000 Subject: [PATCH 3/4] fix(session): reset announced xd mounts on transcript replace The announced-mount baseline persisted across /new, switchSession, and branch, which replace agent.state.messages but only clear session-scoped tool state. A device announced in the old transcript stayed in the cache, so reconnecting it into the fresh history was filtered as already known and never announced, leaving the new conversation unaware of the device. Reset the announced baseline (and any undelivered pending delta) from #clearSessionScopedToolState, so the next notice re-seeds from the new transcript and a reconnecting device announces again. Fixes #6921 (cherry picked from commit d06dde02b9de4aacacd7aea5ee51edc7e524e3fb) --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/session/agent-session.ts | 1 + .../coding-agent/src/session/session-tools.ts | 14 ++++++++ .../agent-session-tool-rebuild-skip.test.ts | 36 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 525dbdeb5..b3a219abc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed `xd://` mount notices re-announcing already-known devices on session resume / host reconnect: the notice was diff-gated only against the in-memory mount set, which reset each resume, so reconnecting MCP/RPC-host devices re-spliced a redundant developer message into history and busted the provider prompt-cache prefix (re-billing the whole suffix at full price on metered providers). Notices now carry a structured `{ added, removed }` payload and are gated against the devices persisted history already announced—including legacy rendered notices from before the structured payload—so a resume that re-establishes the same inventory emits nothing ([#6921](https://github.com/can1357/oh-my-pi/issues/6921)). +- Fixed `xd://` mount notices re-announcing already-known devices on session resume / host reconnect: the notice was diff-gated only against the in-memory mount set, which reset each resume, so reconnecting MCP/RPC-host devices re-spliced a redundant developer message into history and busted the provider prompt-cache prefix (re-billing the whole suffix at full price on metered providers). Notices now carry a structured `{ added, removed }` payload and are gated against the devices persisted history already announced—including legacy rendered notices from before the structured payload—so a resume that re-establishes the same inventory emits nothing. The announced baseline is reset when the transcript is replaced (`/new`, `switchSession`, `branch`), so a device reconnecting into the fresh history announces again ([#6921](https://github.com/can1357/oh-my-pi/issues/6921)). ## [17.1.8] - 2026-07-28 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index d2eb9e249..adf7efa2a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4337,6 +4337,7 @@ export class AgentSession { this.agent.clearDeferredToolDirectives(); this.#toolChoiceQueue.clear(); this.#tools.clearAcpPermissionDecisions(); + this.#tools.resetAnnouncedMounts(); } /** diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index 7be29d5c8..157a83e3f 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -694,6 +694,20 @@ export class SessionTools { this.#host.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev"); } + /** + * Forget mount-notice tracking for a replaced transcript. Called when session + * history is swapped wholesale (`/new`, `switchSession`, `branch`): the base + * system prompt is rebuilt from the current tool set, so the previous + * transcript's announced baseline and any undelivered delta no longer apply. + * The next notice re-seeds from the new transcript, and a device reconnecting + * into it announces again. + */ + resetAnnouncedMounts(): void { + this.#announcedMounts.clear(); + this.#announcedMountsSeeded = false; + this.#pendingXdevMountDelta = undefined; + } + /** * Seed {@link #announcedMounts} from persisted mount notices the first time a * notice is consumed. On resume the in-memory mount set is rebuilt from diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index ff8414f1a..b4b561a76 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -997,6 +997,42 @@ These tools became available: expect(fetchText).not.toContain("xd://mcp__nucleus_search"); }); + it("re-announces a device after the transcript is replaced by /new", async () => { + const xdev = createTestXdevState(); + const { session } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { + xdev, + responses: [{ content: ["ok"] }, { content: ["ok"] }], + }); + const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus"); + + // Announce the device in the original transcript. + await session.refreshMCPTools([search]); + await session.prompt("hello"); + expect( + session.agent.state.messages.filter( + message => message.role === "custom" && message.customType === "xdev-mount-notice", + ), + ).toHaveLength(1); + + // /new swaps in a fresh transcript that no longer carries the notice. A + // resume/reconnect rebuilds the mount set from scratch, so model the device + // dropping out across the boundary. + await session.newSession(); + xdev.mountedNames.clear(); + + // The same device reconnects into the new transcript: because the announced + // baseline was reset with the transcript, it must announce again (otherwise + // the new conversation never learns the device is available). + await session.refreshMCPTools([search]); + await session.prompt("world"); + const newTranscriptNotices = session.agent.state.messages.filter( + (message): message is CustomMessage => message.role === "custom" && message.customType === "xdev-mount-notice", + ); + expect(newTranscriptNotices).toHaveLength(1); + const text = typeof newTranscriptNotices[0].content === "string" ? newTranscriptNotices[0].content : ""; + expect(text).toContain("xd://mcp__nucleus_search"); + }); + it("keeps xd:// mount deltas model-visible without rendering them during quiet startup", async () => { const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { xdev: createTestXdevState(), From 359dfb5b8f32ba5a28d50b7803f0f383a4f803de Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 28 Jul 2026 17:16:45 +0000 Subject: [PATCH 4/4] fix(session): keep pending xd mount delta on transcript reset The previous reset dropped #pendingXdevMountDelta alongside the announced baseline. Unlike /new and different-session switchSession, branch() does not rebuild the base system prompt afterward, and because the device is already in mountedNames no later refresh re-queues an add delta. Dropping the undelivered delta therefore left the branched transcript unaware of a still-mounted discoverable device. Only the announced baseline is reset now; pending adds (still-live mounts awaiting delivery) survive and announce on the next prompt in the new transcript. Redundant on /new (the rebuilt prompt lists them too) but harmless, and correct for branch. Fixes #6921 (cherry picked from commit 5866440f27c15a320657e25fbfa0d2d65aad1fa2) --- .../coding-agent/src/session/session-tools.ts | 17 ++++++----- .../agent-session-tool-rebuild-skip.test.ts | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index 157a83e3f..4479cffa2 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -695,17 +695,20 @@ export class SessionTools { } /** - * Forget mount-notice tracking for a replaced transcript. Called when session - * history is swapped wholesale (`/new`, `switchSession`, `branch`): the base - * system prompt is rebuilt from the current tool set, so the previous - * transcript's announced baseline and any undelivered delta no longer apply. - * The next notice re-seeds from the new transcript, and a device reconnecting - * into it announces again. + * Forget the announced-mount baseline for a replaced transcript. Called when + * session history is swapped wholesale (`/new`, `switchSession`, `branch`): the + * previous transcript's persisted notices no longer apply, so the next notice + * re-seeds from the new history and a device reconnecting into it announces + * again. + * + * The pending delta is deliberately preserved: it holds mounts that are still + * live but not yet delivered to the model, and `branch()` does not rebuild the + * base system prompt, so dropping it would leave the branched transcript + * unaware of a still-mounted device that no later refresh would re-queue. */ resetAnnouncedMounts(): void { this.#announcedMounts.clear(); this.#announcedMountsSeeded = false; - this.#pendingXdevMountDelta = undefined; } /** diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index b4b561a76..aa5dc2209 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -1033,6 +1033,34 @@ These tools became available: expect(text).toContain("xd://mcp__nucleus_search"); }); + it("preserves an undelivered mount notice across a branch that does not rebuild the prompt", async () => { + const { session } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { + xdev: createTestXdevState(), + responses: [{ content: ["ok"] }, { content: ["ok"] }], + }); + session.subscribe(() => {}); + const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus"); + + // A user turn establishes a branch point. + await session.prompt("first"); + // The device mounts but the user branches before the next prompt consumes + // its queued notice. `branch()` does not rebuild the base system prompt, so + // the delta is the only channel that can tell the branched transcript the + // device exists. + await session.refreshMCPTools([search]); + const branchable = session.getUserMessagesForBranching(); + expect(branchable.length).toBeGreaterThan(0); + await session.branch(branchable[0].entryId); + + await session.prompt("second"); + const notices = session.agent.state.messages.filter( + (message): message is CustomMessage => message.role === "custom" && message.customType === "xdev-mount-notice", + ); + expect(notices).toHaveLength(1); + const text = typeof notices[0].content === "string" ? notices[0].content : ""; + expect(text).toContain("xd://mcp__nucleus_search"); + }); + it("keeps xd:// mount deltas model-visible without rendering them during quiet startup", async () => { const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { xdev: createTestXdevState(),