From 82dc0d43ae8d0d7c81c92102635c56c5b40c72a8 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 28 Jul 2026 16:50:49 +0000 Subject: [PATCH] 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"] }],