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)
This commit is contained in:
roboomp
2026-07-28 17:03:01 +00:00
committed by can1357
parent 82dc0d43ae
commit 13b1077d3e
4 changed files with 52 additions and 1 deletions
+1 -1
View File
@@ -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
@@ -4337,6 +4337,7 @@ export class AgentSession {
this.agent.clearDeferredToolDirectives();
this.#toolChoiceQueue.clear();
this.#tools.clearAcpPermissionDecisions();
this.#tools.resetAnnouncedMounts();
}
/**
@@ -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
@@ -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(),