fix(coding-agent): skipped xd:// mounting when write tool is not granted
- Prevent xdev state allocation and tool mounting in sessions lacking a write tool. - Expose discoverable tools top-level instead of auto-granting write transports.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed sessions without a granted `write` tool hiding discoverable and MCP tools behind the unusable `xd://` transport; those sessions now disable device mounting and expose the tools directly without gaining write access.
|
||||
- Fixed collab guest prompts being sent to models as unframed developer context, so guest messages now retain their transcript attribution while reaching the model as prioritized user interjections ([#7288](https://github.com/can1357/oh-my-pi/issues/7288)).
|
||||
- Fixed `/memory stats` and `/memory diagnose` showing "Memory stats is not available for the off backend" when memory is off, in both the TUI and ACP/RPC slash-command handlers; the off backend now says memory is off directly instead of naming itself as an unsupported backend ([#7251](https://github.com/can1357/oh-my-pi/pull/7251) by [@KennethHoff](https://github.com/KennethHoff)).
|
||||
- Fixed `/reload-plugins` retaining stale context-file contents and activation state in the current system prompt ([#7258](https://github.com/can1357/oh-my-pi/issues/7258)).
|
||||
|
||||
@@ -4254,7 +4254,7 @@ export const SETTINGS_SCHEMA = {
|
||||
group: "Discovery & MCP",
|
||||
label: "xd:// Tools",
|
||||
description:
|
||||
"Mount rarely-used (discoverable) tools under xd:// device URLs driven via read/write instead of shipping their schemas on every request. Disable to expose every enabled tool top-level.",
|
||||
"Mount rarely-used (discoverable) tools under xd:// device URLs driven via read/write instead of shipping their schemas on every request. Sessions without a granted write tool skip mounting and expose every tool top-level. Disable to expose every enabled tool top-level.",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -2724,10 +2724,11 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
|
||||
|
||||
// Existing staged/device paths need write registered before active-set assembly.
|
||||
// Deferred MCP also registers it now, but refresh activates it only after a server connects.
|
||||
// xd:// mounts never register write: xdev state only exists when the session
|
||||
// already granted a write tool (see createTools), so mounting rides that grant.
|
||||
const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
|
||||
const hasXdevTools = (toolSession.xdev?.mountedNames.size ?? 0) > 0;
|
||||
const planModeAvailable = settings.get("plan.enabled");
|
||||
if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
|
||||
if (!restrictToolNames && (hasDeferrableTools || planModeAvailable || deferMCPDiscoveryForUI)) {
|
||||
await ensureWriteRegistered();
|
||||
}
|
||||
|
||||
@@ -2984,6 +2985,9 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
|
||||
const xdevReadAvailable =
|
||||
builtInRegistryToolNames.has("read") &&
|
||||
(explicitlyRequestedToolNameSet === undefined || explicitlyRequestedToolNameSet.has("read"));
|
||||
const xdevWriteAvailable =
|
||||
builtInRegistryToolNames.has("write") &&
|
||||
(explicitlyRequestedToolNameSet === undefined || explicitlyRequestedToolNameSet.has("write"));
|
||||
const initialRequestedActiveToolNames = options.toolNames
|
||||
? requestedActiveToolNames
|
||||
: requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
|
||||
@@ -3029,23 +3033,23 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
|
||||
|
||||
// Partition the initial enabled set for the xd:// transport. Tool instances
|
||||
// remain in the canonical map; only presentation names move between layers.
|
||||
// Mounting requires both transport halves in the granted set (`read xd://`
|
||||
// discovers, `write xd://<tool>` executes); a session without either keeps
|
||||
// every tool top-level instead of auto-granting the missing transport.
|
||||
if (toolSession.xdev) {
|
||||
const topLevelToolNames: string[] = [];
|
||||
const mountedNames: string[] = [];
|
||||
for (const name of initialToolNames) {
|
||||
const tool = toolRegistry.get(name);
|
||||
const explicitlyRequested = explicitlyRequestedToolNameSet?.has(name) === true;
|
||||
if (tool && xdevReadAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
|
||||
if (tool && xdevReadAvailable && xdevWriteAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
|
||||
mountedNames.push(name);
|
||||
else topLevelToolNames.push(name);
|
||||
}
|
||||
const writeTransportAvailable = mountedNames.length === 0 || (await ensureWriteRegistered());
|
||||
toolSession.xdev.mountedNames.clear();
|
||||
if (writeTransportAvailable) {
|
||||
for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
|
||||
initialToolNames = topLevelToolNames;
|
||||
if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
|
||||
}
|
||||
for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
|
||||
initialToolNames = topLevelToolNames;
|
||||
if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
|
||||
}
|
||||
|
||||
setActiveToolNames(initialToolNames);
|
||||
|
||||
@@ -574,24 +574,28 @@ export class SessionTools {
|
||||
/** Applies an enabled tool set and reconciles its `xd://` partition. */
|
||||
async applyActiveToolsByName(toolNames: string[]): Promise<void> {
|
||||
toolNames = normalizeToolNames(toolNames);
|
||||
let builtInWriteAvailable = this.#builtInToolNames.has("write");
|
||||
if (toolNames.includes("write") && !builtInWriteAvailable) {
|
||||
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
|
||||
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
|
||||
}
|
||||
const selectedTools = toolNames.flatMap(name => {
|
||||
const tool = this.#toolRegistry.get(name);
|
||||
return tool ? [{ name, tool }] : [];
|
||||
});
|
||||
const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
|
||||
const xdevWriteAvailable = builtInWriteAvailable && selectedTools.some(({ name }) => name === "write");
|
||||
const isPresentationPinned = (name: string): boolean =>
|
||||
this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
|
||||
const mountCandidates = selectedTools.filter(
|
||||
({ name, tool }) =>
|
||||
this.#xdev !== undefined && xdevReadAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool),
|
||||
this.#xdev !== undefined &&
|
||||
xdevReadAvailable &&
|
||||
xdevWriteAvailable &&
|
||||
!isPresentationPinned(name) &&
|
||||
isMountableUnderXdev(tool),
|
||||
);
|
||||
|
||||
let builtInWriteAvailable = this.#builtInToolNames.has("write");
|
||||
if (mountCandidates.length > 0 && !builtInWriteAvailable) {
|
||||
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
|
||||
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
|
||||
}
|
||||
const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
|
||||
const mountNames = new Set(mountCandidates.map(({ name }) => name));
|
||||
const tools: AgentTool[] = [];
|
||||
const validToolNames: string[] = [];
|
||||
for (const { name, tool } of selectedTools) {
|
||||
|
||||
@@ -667,8 +667,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
// Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
|
||||
// MCP tools. Structured children must expose only their host-provided names,
|
||||
// so never allocate a registry that later SDK assembly could populate.
|
||||
// The transport rides read/write, so a session granted no write tool never
|
||||
// allocates xd:// state — its tools are exposed top-level directly instead
|
||||
// of auto-granting a write transport the session was denied.
|
||||
// Explicitly requested built-ins retain their top-level presentation.
|
||||
const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
|
||||
const xdevEnabled =
|
||||
!restrictToolNames && session.settings.get("tools.xdev") && tools.some(tool => tool.name === "write");
|
||||
const mountBuiltinTools = requestedTools === undefined;
|
||||
if (xdevEnabled) {
|
||||
const mountedNames = new Set<string>();
|
||||
@@ -686,14 +690,14 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
};
|
||||
tools = kept;
|
||||
}
|
||||
// The xd:// transport rides read/write: `read xd://` lists+documents devices,
|
||||
// `write xd://<tool>` executes them. Staged previews from deferrable tools
|
||||
// (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
|
||||
// both whenever any device is mounted or a deferrable tool can stage one.
|
||||
// Staged previews from deferrable tools (e.g. ast_edit) resolve through a
|
||||
// `write` to xd://resolve/reject, so retain write whenever one can stage.
|
||||
// xd:// mounting itself never registers write: sessions without a granted
|
||||
// write tool skip mounting entirely (see xdevEnabled above).
|
||||
const xdevMounted = (session.xdev?.mountedNames.size ?? 0) > 0;
|
||||
if (
|
||||
!restrictToolNames &&
|
||||
(tools.some(tool => tool.deferrable === true) || xdevMounted) &&
|
||||
tools.some(tool => tool.deferrable === true) &&
|
||||
!tools.some(tool => tool.name === "write")
|
||||
) {
|
||||
const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
|
||||
|
||||
@@ -457,11 +457,14 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
|
||||
// Ordinary xd:// inventory changes travel through mount notices and do
|
||||
// not affect the global MCP-route guidance or its rebuild signature.
|
||||
await session.setActiveToolPresentation(["read", search.name, catalog.name], [search.name, catalog.name]);
|
||||
await session.setActiveToolPresentation(
|
||||
["read", "write", search.name, catalog.name],
|
||||
[search.name, catalog.name],
|
||||
);
|
||||
expect(session.getMountedXdevToolNames()).toContain(catalog.name);
|
||||
expect(rebuildCount).toBe(1);
|
||||
|
||||
await session.setActiveToolPresentation(["read", search.name], [search.name]);
|
||||
await session.setActiveToolPresentation(["read", "write", search.name], [search.name]);
|
||||
expect(session.getMountedXdevToolNames()).not.toContain(catalog.name);
|
||||
expect(rebuildCount).toBe(1);
|
||||
});
|
||||
@@ -1176,7 +1179,7 @@ These tools became available:
|
||||
expect(delivered[0]).toContain("xd://mcp__nucleus_search");
|
||||
});
|
||||
|
||||
it("keeps lazy write registration while rolling back applied state on rebuild failure", async () => {
|
||||
it("does not register write while rolling back a direct-tool rebuild failure", async () => {
|
||||
let failRebuild = true;
|
||||
const xdevState = createTestXdevState();
|
||||
const { session } = newSession(
|
||||
@@ -1194,13 +1197,14 @@ These tools became available:
|
||||
|
||||
expect(session.getActiveToolNames()).toEqual(activeBefore);
|
||||
expect(session.getMountedXdevToolNames()).toEqual(mountedBefore);
|
||||
expect(session.getToolByName("write")).toBeDefined();
|
||||
expect(session.hasBuiltInTool("write")).toBe(true);
|
||||
expect(session.getToolByName("write")).toBeUndefined();
|
||||
expect(session.hasBuiltInTool("write")).toBe(false);
|
||||
|
||||
failRebuild = false;
|
||||
await session.refreshMCPTools([search]);
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
expect(session.getMountedXdevToolNames()).toContain(search.name);
|
||||
expect(session.getActiveToolNames()).toContain(search.name);
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getMountedXdevToolNames()).not.toContain(search.name);
|
||||
});
|
||||
|
||||
it("rolls back MCP catalog replacement when prompt rebuild fails", async () => {
|
||||
|
||||
@@ -129,18 +129,20 @@ describe("generate_image tool gating", () => {
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("generate_image");
|
||||
});
|
||||
|
||||
it("keeps carried mounted devices under xd after runtime tool selection", async () => {
|
||||
it("keeps ambient tools top-level across runtime selection without write", async () => {
|
||||
const ambientTool = customTool("ambient_search");
|
||||
const session = await sessionWithCustomTools(["read"], [ambientTool]);
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
|
||||
expect(session.getActiveToolNames()).toContain(ambientTool.name);
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
|
||||
await session.setActiveToolsByName(session.getEnabledToolNames());
|
||||
|
||||
expect(session.getActiveToolNames()).not.toContain(ambientTool.name);
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
|
||||
expect(session.getActiveToolNames()).toContain(ambientTool.name);
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicit discoverable tools top-level while mounting ambient MCP-shaped custom tools", async () => {
|
||||
it("exposes ambient MCP-shaped tools directly when write was not granted", async () => {
|
||||
let mcpCalls = 0;
|
||||
const mcpTool = {
|
||||
name: "mcp__test__search",
|
||||
@@ -168,37 +170,37 @@ describe("generate_image tool gating", () => {
|
||||
sessions.push(session);
|
||||
|
||||
expect(session.getActiveToolNames()).toContain("generate_image");
|
||||
expect(session.getActiveToolNames()).not.toContain(mcpTool.name);
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(mcpTool.name);
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain("generate_image");
|
||||
expect(session.getActiveToolNames()).toContain(mcpTool.name);
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
expect(session.getAllToolNames()).toContain(mcpTool.name);
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
const writeTool = session.getToolByName("write");
|
||||
expect(writeTool).toBeDefined();
|
||||
const result = await writeTool!.execute("mcp-xdev-dispatch", {
|
||||
path: `xd://${mcpTool.name}`,
|
||||
content: "{}",
|
||||
});
|
||||
const directTool = session.getToolByName(mcpTool.name);
|
||||
expect(directTool).toBeDefined();
|
||||
const result = await directTool!.execute("mcp-direct-dispatch", {});
|
||||
expect(result.content.find(part => part.type === "text")?.text).toBe("ok");
|
||||
expect(mcpCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("drops transport-only write after the last MCP device disconnects", async () => {
|
||||
it("does not add write for an MCP device when write was omitted", async () => {
|
||||
const session = await sessionWithCustomTools(["read"], [customTool("mcp__test__search", true)]);
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
expect(session.getActiveToolNames()).toContain("mcp__test__search");
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
|
||||
await session.refreshMCPTools([]);
|
||||
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
});
|
||||
|
||||
it("does not pin transport-only write during enabled-set round trips", async () => {
|
||||
it("does not add write during enabled-set round trips", async () => {
|
||||
const session = await sessionWithCustomTools(["read"], [customTool("mcp__test__search", true)]);
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
|
||||
await session.setActiveToolsByName(session.getEnabledToolNames());
|
||||
await session.refreshMCPTools([]);
|
||||
expect(session.getActiveToolNames()).toContain("mcp__test__search");
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
|
||||
await session.refreshMCPTools([]);
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
});
|
||||
|
||||
@@ -210,14 +212,27 @@ describe("generate_image tool gating", () => {
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
});
|
||||
|
||||
it("preserves write while a non-MCP device remains mounted", async () => {
|
||||
it("unmounts devices when write is removed at runtime", async () => {
|
||||
const ambientTool = customTool("ambient_search");
|
||||
const session = await sessionWithCustomTools(["read", "write"], [ambientTool]);
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
|
||||
|
||||
await session.setActiveToolsByName(["read", ambientTool.name]);
|
||||
|
||||
expect(session.getActiveToolNames()).toContain(ambientTool.name);
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps all remaining tools top-level after MCP disconnect without write", async () => {
|
||||
const ambientTool = customTool("ambient_search");
|
||||
const session = await sessionWithCustomTools(["read"], [ambientTool, customTool("mcp__test__search", true)]);
|
||||
|
||||
await session.refreshMCPTools([]);
|
||||
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain(ambientTool.name);
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
expect(session.getActiveToolNames()).toContain(ambientTool.name);
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps ambient custom tools top-level when an explicit session omitted read", async () => {
|
||||
@@ -298,7 +313,7 @@ describe("generate_image tool gating", () => {
|
||||
expect(session.getActiveToolNames()).toContain(rpcTool.name);
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).not.toContain(rpcTool.name);
|
||||
});
|
||||
it("activates write when an RPC host tool mounts under xd://", async () => {
|
||||
it("exposes newly discovered RPC tools directly when write was omitted", async () => {
|
||||
const { session } = await createAgentSession({
|
||||
cwd: registryDir,
|
||||
agentDir: registryDir,
|
||||
@@ -326,7 +341,8 @@ describe("generate_image tool gating", () => {
|
||||
};
|
||||
await session.refreshRpcHostTools([rpcTool]);
|
||||
|
||||
expect(session.getXdevToolEntries().map(entry => entry.name)).toContain("rpc_search");
|
||||
expect(session.getActiveToolNames()).toContain("write");
|
||||
expect(session.getActiveToolNames()).toContain("rpc_search");
|
||||
expect(session.getActiveToolNames()).not.toContain("write");
|
||||
expect(session.getXdevToolEntries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,10 +150,21 @@ describe("createTools", () => {
|
||||
|
||||
it("creates xd:// presentation state without remounting explicitly requested built-ins", async () => {
|
||||
const session = createTestSession();
|
||||
const tools = await createTools(session, ["read", "lsp"]);
|
||||
const tools = await createTools(session, ["read", "lsp", "write"]);
|
||||
|
||||
expect(session.xdev).toBeDefined();
|
||||
expect(session.xdev?.mountedNames.size).toBe(0);
|
||||
expect(tools.map(tool => tool.name)).toEqual(["read", "lsp", "write"]);
|
||||
});
|
||||
|
||||
it("skips xd:// state entirely when the session grants no write tool", async () => {
|
||||
// The xd:// transport rides `write xd://<tool>`; without a granted write
|
||||
// tool nothing can dispatch a device, so no state is allocated and later
|
||||
// SDK assembly exposes custom/MCP tools top-level instead.
|
||||
const session = createTestSession();
|
||||
const tools = await createTools(session, ["read", "lsp"]);
|
||||
|
||||
expect(session.xdev).toBeUndefined();
|
||||
expect(tools.map(tool => tool.name)).toEqual(["read", "lsp"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user