Files
PaperClipAI/server/src/services/native-runtime/assigned-mcp-tools.test.ts
T
DottaandPaperclip 21ee5c9c85 feat(sentry): add opt-in run failure reporting with saved run references (#16300)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs retain execution results and output in the instance.
> - Operators can report run failures to Sentry for investigation.
> - A backend Sentry DSN currently enables those reports with no
separate choice.
> - A repair bot also needs the exact instance, company, agent, and
saved run.
> - This pull request adds an explicit reporting mode and authenticated
run references.
> - The benefit is that operators can investigate every failure on
selected deployments while keeping other deployments quiet.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Optional server reporting of terminal agent run failures to Sentry.

**Current behavior**

A backend DSN enables run failure reports. Known account, quota, and
connection failures are excluded. Reports omit company and agent
references. Runs without a task are skipped. Tool-name collision errors
omit the conflicting bindings.

**Proposed behavior**

Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the existing
filters. Set it to `all` to report every failed or timed-out run,
including known setup and quota failures. The default is `off`. Expected
stops stay excluded. Each report supplies identifiers and API paths that
a bot can use with its own authorized Paperclip connection.

**Reason and benefit**

Operators can enable broad failure reporting on one staging deployment.
A bot can fetch the saved run, events, and logs instead of relying on a
short error label or copying complete transcripts into Sentry.

**Breaking changes**

A backend DSN alone no longer enables run failure reports. Existing
operators must set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain
the previous policy. Ordinary server exception capture remains enabled
by the DSN. Failure grouping gains the phase and original cause code
when available.

Refs #16281, #16297, #16298. These cover related tool discovery and
failure diagnostics. This change retains their existing boundaries and
does not depend on their open changes.

## What Changed

- Add `off`, `unexpected`, and `all` modes. Missing and invalid values
keep reporting off before diagnostic reads.
- Keep the Sentry backend DSN and audited optional SDK requirements.
- Include company, agent, run, and optional task identifiers. Include
versioned paths for the saved run, events, log, and task. Add absolute
run URLs only for a valid public HTTP(S) origin.
- Report company runs without a task. Prefer the first-class issue
binding when selecting the task reference.
- Select saved native provider/model settings, timestamps, and original
failure codes. Preserve gateway reason codes through redacted exception
projection.
- Group by error code, adapter, and available phase/cause. Keep run IDs
and error text out of grouping.
- Include the tool names and connection bindings in collision errors
without copying tool schemas or provider metadata.
- Document configuration, upgrade behavior, bot access, redaction,
retention, and best-effort delivery.

## Verification

- All 56 checks passed for `cccb47a721eaa1cc36e63278fc177b9101477541`.
Two unrelated Storybook jobs were skipped by their path filters. No
checks remain pending.
- Complete [Paperclip CI
run](https://github.com/paperclipai/paperclip/actions/runs/38591422284):
passed. This covers general and serialized tests, Runner checks, browser
E2E shards, typecheck, build, release-registry checks, canary dry run,
and Docker checks.
- Greptile reviewed that exact head: 5/5, with no unresolved threads or
actionable findings. Superagent, Socket, and Snyk checks passed.
- Local focused reporting, diagnostics, collision, and real SDK contract
tests: 410 passed across five suites. This includes the external-wake
filter in both reporting modes.
- The audited `@sentry/node@10.71.0` tests use an in-memory transport.
No events were sent to Sentry.
- `pnpm -r typecheck` and `pnpm build`: passed locally after the final
rebase.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`:
passed.
- The full local `pnpm test:run` command was stopped to avoid
duplicating the complete CI shards. It did not finish locally. The full
suite passed in CI.

## Risks

- The new default disables existing run failure exports until the
operator selects a mode. The upgrade setting is documented.
- `all` can increase event volume and include known user-action
failures. Secret redaction and diagnostic limits still apply.
- Reporting remains asynchronous and best effort. This PR adds no
durable delivery queue and does not install a bot or Sentry alert rule.
- API references grant no authority. Existing company, private-task, and
run-output checks still apply. Complete transcripts stay in Paperclip.
- This changes optional Sentry error reporting. It changes neither
first-party Telemetry nor OpenTelemetry span exports. It adds no
database migration.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, shell, file editing, and
test execution. The exact backend deployment ID and context window are
not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 10:56:40 -05:00

313 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js";
function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor {
return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk };
}
function fixture(tools: ToolGatewayDescriptor[]) {
const listToolsForNamedGateway = vi.fn().mockResolvedValue(tools);
const executeTool = vi.fn().mockResolvedValue({ status: "completed", result: { content: [{ type: "text", text: "memory found" }] } });
const gateway = { listToolsForNamedGateway, executeTool } as unknown as ToolGatewayService;
return { gateway, listToolsForNamedGateway, executeTool, gatewayPublicId: "gateway-fixture", bearerToken: "private-run-token-never-project" };
}
describe("assigned MCP runner tools", () => {
const searchName = "paperclip_search_assigned_tools";
const callName = "paperclip_call_assigned_tool";
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
const read = descriptor("memory.search");
const write = descriptor("memory.store", "write");
const f = fixture([read, structuredClone(read), write, structuredClone(write)]);
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
expect(direct).toHaveLength(2);
expect(assigned.definitions(tools => tools.length <= 2)).toEqual(direct);
expect(assigned.definitions(tools => tools[0]?.name === searchName).map(tool => tool.name)).toEqual([searchName, callName]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } }, "planning"))
.resolves.toEqual({ tools: [direct[0]], nextOffset: null });
await assigned.execute({ tool: callName, arguments: { name: direct[0]!.name, arguments: { query: "fixture" } } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tool: read.name }));
f.listToolsForNamedGateway.mockResolvedValue([write, structuredClone(write)]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } }))
.resolves.toEqual({ tools: [direct[1]], nextOffset: null });
});
it.each([
{ risk: "write" as const },
{ connectionId: "another-connection" },
{ parametersSchema: { type: "object", properties: { secret: { type: "string" } } } },
])("rejects conflicting definitions with the same gateway name: %j", async change => {
const tool = descriptor("memory.search");
const f = fixture([tool, { ...tool, ...change }]);
await expect(createAssignedMcpTools(f)).rejects.toMatchObject({ reasonCode: "tool_name_collision" });
expect(f.executeTool).not.toHaveBeenCalled();
});
it("identifies the conflicting tool and both connection bindings without exporting descriptor metadata", async () => {
const tool = { ...descriptor("calendar.search"), connectionId: "first-connection", providerMetadata: { token: "private-token" } };
const f = fixture([tool, { ...tool, connectionId: "second-connection" }]);
await expect(createAssignedMcpTools(f)).rejects.toMatchObject({ reasonCode: "tool_name_collision",
message: 'Conflicting tool definitions share the name "calendar.search" (connections ["first-connection","second-connection"]).' });
expect(f.executeTool).not.toHaveBeenCalled();
});
it("keeps same-action tools from distinct connections separately callable", async () => {
const f = fixture([descriptor("mcp.memory-first:search"), descriptor("mcp.memory-second:search")]);
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
expect(new Set(direct.map(tool => tool.name)).size).toBe(2);
for (const tool of direct) await assigned.execute({ tool: tool.name as string, arguments: {} });
expect(f.executeTool.mock.calls.map(([call]) => call.tool)).toEqual(["mcp.memory-first:search", "mcp.memory-second:search"]);
});
it("pages through an oversized catalog and calls every tool through the original gateway", async () => {
const f = fixture(Array.from({ length: 224 }, (_, i) => descriptor(`app.action_${i}`)));
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
const compact = assigned.definitions(tools => tools.length <= 200);
expect(compact.map(tool => tool.name)).toEqual([searchName, callName]);
expect(assigned.definitions(tools => tools.length <= 224)).toEqual(direct);
expect(() => assigned.definitions(() => false)).toThrow("assigned_mcp_tool_catalog_capacity_exceeded");
const seen: string[] = [];
let offset: number | null = 0;
do {
const page = await assigned.execute({ tool: searchName, arguments: { query: "", offset, limit: 20 } }) as {
tools: Array<{ name: string; inputSchema: unknown }>; nextOffset: number | null;
};
expect(page.tools.length).toBeLessThanOrEqual(20);
for (const tool of page.tools) {
seen.push(tool.name);
expect(tool.inputSchema).toEqual(descriptor("any").parametersSchema);
await assigned.execute({ tool: callName, arguments: { name: tool.name, arguments: { query: "fixture" } } });
}
offset = page.nextOffset;
} while (offset !== null);
expect(seen).toEqual(direct.map(tool => tool.name).sort());
expect(f.executeTool).toHaveBeenCalledTimes(224);
expect(new Set(f.executeTool.mock.calls.map(([call]) => call.tool)).size).toBe(224);
for (const [call] of f.executeTool.mock.calls) {
expect(call).toMatchObject({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, parameters: { query: "fixture" } });
expect(call).not.toHaveProperty("approvedActionRequestId");
}
});
it("searches only pinned tools still granted by fresh discovery without projecting metadata", async () => {
const f = fixture([descriptor("calendar.search"), descriptor("mail.search"), descriptor("calendar.remove", "write")]);
const assigned = await createAssignedMcpTools(f);
f.listToolsForNamedGateway.mockResolvedValue([
{ ...descriptor("calendar.search"), providerMetadata: { token: "secret-provider-token" } },
descriptor("calendar.remove", "write"), descriptor("calendar.new_grant"),
]);
const result = await assigned.execute({ tool: searchName, arguments: { query: "calendar" } }, "planning");
expect(result).toEqual({ tools: [assigned.definitions()[0]], nextOffset: null });
expect(JSON.stringify(result)).not.toMatch(/private-|secret-provider|gateway-fixture|new_grant/);
f.listToolsForNamedGateway.mockRejectedValue(new Error("gateway_token_revoked"));
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } })).rejects.toThrow("gateway_token_revoked");
});
it.each(["planning", "ask"] as const)("preserves pinned and fresh %s restrictions through the call wrapper", async mode => {
const f = fixture([descriptor("read"), descriptor("write", "write")]);
const assigned = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode: mode });
const call = { tool: callName, arguments: { name: assigned.definitions()[1]!.name, arguments: {} } };
await expect(assigned.execute(call, mode)).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(restricted.execute(call, "standard")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
const discovery = await restricted.execute({ tool: searchName, arguments: { query: "" } }, "standard");
expect(discovery).toEqual({ tools: [assigned.definitions()[0]], nextOffset: null });
});
it.each(["approval_required", "connection_revoked", "tool_error"])("preserves %s through the call wrapper", async reason => {
const f = fixture([descriptor("write", "write")]);
const assigned = await createAssignedMcpTools(f);
const error = new ToolGatewayHttpError(403, "Denied", reason);
f.executeTool.mockRejectedValue(error);
await expect(assigned.execute({ tool: callName, arguments: { name: assigned.definitions()[0]!.name, arguments: {} } })).rejects.toBe(error);
});
it("bounds search pages by bytes and rejects invalid wrapper arguments and unassigned targets", async () => {
const f = fixture(Array.from({ length: 4 }, (_, i) => ({
...descriptor(`large_${i}`),
parametersSchema: { type: "object", description: "x".repeat(250 * 1024) },
})));
const assigned = await createAssignedMcpTools(f);
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 20 } }) as { tools: unknown[]; nextOffset: number };
expect(page.tools).toHaveLength(2);
expect(page.nextOffset).toBe(2);
for (const args of [null, [], { query: "x".repeat(201) }, { query: "", offset: -1 }, { query: "", limit: 21 }, { query: "", offset: 0.5 }]) {
await expect(assigned.execute({ tool: searchName, arguments: args })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
}
for (const name of ["unassigned", searchName, callName]) {
await expect(assigned.execute({ tool: callName, arguments: { name, arguments: {} } })).rejects.toThrow("assigned_mcp_tool_unknown");
}
await expect(assigned.execute({ tool: callName, arguments: { name: "unassigned", arguments: [] } })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
expect(f.executeTool).not.toHaveBeenCalled();
});
it("keeps individually oversized schemas and later tools discoverable with bounded schema chunks", async () => {
const schema = { type: "object", description: "\u0000🙂".repeat(150_000), properties: {} };
const f = fixture([{ ...descriptor("a_large"), parametersSchema: schema }, descriptor("z_small")]);
const assigned = await createAssignedMcpTools(f);
const large = assigned.definitions()[0]!.name as string;
const page = await assigned.execute({ tool: searchName, arguments: { query: "", limit: 1 } }) as {
tools: Array<Record<string, unknown>>; nextOffset: number;
};
expect(page.tools).toEqual([{ name: large, description: "a_large: Use a_large", inputSchemaRef: large }]);
expect(page.nextOffset).toBe(1);
await expect(assigned.execute({ tool: searchName, arguments: { query: "", offset: page.nextOffset } }))
.resolves.toEqual({ tools: [assigned.definitions()[1]], nextOffset: null });
let schemaOffset: number | null = 0;
let serialized = "";
do {
const chunk = await assigned.execute({ tool: searchName, arguments: { query: "", schemaTool: large, schemaOffset } }) as {
schemaJson: string; nextSchemaOffset: number | null;
};
expect(Buffer.byteLength(JSON.stringify(chunk))).toBeLessThan(640 * 1024);
serialized += chunk.schemaJson;
schemaOffset = chunk.nextSchemaOffset;
} while (schemaOffset !== null);
expect(JSON.parse(serialized)).toEqual(schema);
await assigned.execute({ tool: callName, arguments: { name: large, arguments: {} } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tool: "a_large" }));
f.listToolsForNamedGateway.mockResolvedValue([descriptor("z_small")]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "", schemaTool: large } })).rejects.toThrow("assigned_mcp_tool_unknown");
await expect(assigned.execute({ tool: searchName, arguments: { query: "", schemaOffset: 1 } })).rejects.toThrow("assigned_mcp_tool_invalid_arguments");
});
it("requires a configured gateway registered for the exact database instance", () => {
const firstDb = {} as Db;
const secondDb = {} as Db;
const first = fixture([]).gateway;
const second = fixture([]).gateway;
expect(() => getAssignedMcpGateway(firstDb)).toThrow("assigned_mcp_gateway_unavailable");
registerAssignedMcpGateway(firstDb, first);
expect(getAssignedMcpGateway(firstDb)).toBe(first);
expect(() => getAssignedMcpGateway(secondDb)).toThrow("assigned_mcp_gateway_unavailable");
registerAssignedMcpGateway(secondDb, second);
expect(getAssignedMcpGateway(secondDb)).toBe(second);
expect(getAssignedMcpGateway(firstDb)).toBe(first);
registerAssignedMcpGateway(firstDb, second);
expect(getAssignedMcpGateway(firstDb)).toBe(second);
});
it("projects only authorized gateway tools with stable bounded collision-resistant names", async () => {
const names = ["memory.search", "memory-search", "x".repeat(120), "🧠"];
const f = fixture(names.map(name => descriptor(name)));
const first = await createAssignedMcpTools(f);
const reversed = await createAssignedMcpTools({ ...f, gateway: fixture([...names].reverse().map(name => descriptor(name))).gateway });
const projected = first.definitions().map(tool => tool.name as string);
expect(new Set(projected).size).toBe(names.length);
for (const name of projected) {
expect(name).toMatch(/^app_[a-zA-Z0-9_]+_[a-f0-9]{12}$/);
expect(name.length).toBeLessThanOrEqual(64);
expect(first.has(name)).toBe(true);
}
expect(reversed.definitions().map(tool => tool.name)).toEqual([...projected].reverse());
expect(f.listToolsForNamedGateway).toHaveBeenCalledWith({ gatewayPublicId: f.gatewayPublicId, bearerToken: f.bearerToken });
});
it("keeps the provider and action readable for fully namespaced gateway tools", async () => {
const prefix = "mcp.app-gallery-mem0-60062edb-c211-4aae-a787-b4fa12a5ea24-f4ece062";
const tools = ["add-memory", "search-memories", "delete-memory"].map(action => ({
...descriptor(`${prefix}:${action}`), displayName: `Mem0 ${action}`,
}));
const assigned = await createAssignedMcpTools(fixture(tools));
for (const [index, definition] of assigned.definitions().entries()) {
const name = definition.name as string;
expect(name).toContain("mem0");
expect(name).toContain(tools[index]!.name.split(":").at(-1)!.replaceAll("-", "_"));
expect(name.length).toBeLessThanOrEqual(64);
expect(definition.description).toBe(`${tools[index]!.displayName}: ${tools[index]!.description}`);
}
const long = await createAssignedMcpTools(fixture([descriptor(`${prefix}:${"long-action-".repeat(10)}`)]));
expect((long.definitions()[0]!.name as string).length).toBeLessThanOrEqual(64);
});
it("does not project gateway secrets, URLs, or provider metadata and returns only the tool result", async () => {
const tool = { ...descriptor("recall"), providerMetadata: { token: "private-provider-token", url: "https://private-gateway.example" } };
const f = fixture([tool]);
f.executeTool.mockResolvedValue({ status: "completed", invocationId: "internal", result: { memories: [] } });
const assigned = await createAssignedMcpTools(f);
const definitions = assigned.definitions();
expect(Object.keys(definitions[0]!)).toEqual(["name", "description", "inputSchema"]);
expect(JSON.stringify(definitions)).not.toMatch(/private-|gateway-fixture|https:/);
const args = { query: "synthetic memory" };
expect(await assigned.execute({ tool: definitions[0]!.name as string, arguments: args })).toEqual({ memories: [] });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, tool: "recall", parameters: args });
(definitions[0]!.inputSchema as Record<string, unknown>).type = "string";
expect(assigned.definitions()[0]!.inputSchema).toEqual(tool.parametersSchema);
});
it("rejects unknown names without invoking the gateway", async () => {
const f = fixture([descriptor("recall")]);
const assigned = await createAssignedMcpTools(f);
expect(assigned.has("unassigned")).toBe(false);
await expect(assigned.execute({ tool: "unassigned", arguments: {} })).rejects.toThrow("assigned_mcp_tool_unknown");
expect(f.executeTool).not.toHaveBeenCalled();
});
it.each(["planning", "ask"] as const)("exposes and permits only read tools in %s mode", async workMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write"), descriptor("forget", "destructive")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode });
expect(restricted.definitions()).toEqual([standard.definitions()[0]]);
for (const tool of standard.definitions().slice(1)) {
await expect(restricted.execute({ tool: tool.name as string, arguments: {} })).rejects.toThrow("paperclip_runner_tool_mode_denied");
}
expect(f.executeTool).not.toHaveBeenCalled();
await restricted.execute({ tool: restricted.definitions()[0]!.name as string, arguments: {} });
expect(f.executeTool).toHaveBeenCalledOnce();
});
it("uses the gateway for write and destructive tools under standard mode without approval overrides", async () => {
const f = fixture([descriptor("remember", "write"), descriptor("forget", "destructive")]);
const assigned = await createAssignedMcpTools(f);
for (const tool of assigned.definitions()) await assigned.execute({ tool: tool.name as string, arguments: {} });
expect(f.executeTool).toHaveBeenCalledTimes(2);
for (const [call] of f.executeTool.mock.calls) expect(call).not.toHaveProperty("approvedActionRequestId");
});
it.each(["planning", "ask"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode: currentMode });
const write = { tool: standard.definitions()[1]!.name as string, arguments: {} };
await expect(standard.execute(write, currentMode)).rejects.toThrow("paperclip_runner_tool_mode_denied");
await expect(restricted.execute(write, "standard")).rejects.toThrow("paperclip_runner_tool_mode_denied");
expect(f.executeTool).not.toHaveBeenCalled();
await standard.execute({ tool: standard.definitions()[0]!.name as string, arguments: {} }, currentMode);
expect(f.executeTool).toHaveBeenCalledOnce();
});
it.each(["tool_error", "approval_required", "connection_revoked"])("propagates %s without converting it to success", async reason => {
const f = fixture([descriptor("recall")]);
const error = new ToolGatewayHttpError(403, "Gateway refused execution", reason);
f.executeTool.mockRejectedValue(error);
const assigned = await createAssignedMcpTools(f);
await expect(assigned.execute({ tool: assigned.definitions()[0]!.name as string, arguments: {} })).rejects.toBe(error);
});
it("propagates discovery denial", async () => {
const f = fixture([]);
const error = new Error("gateway_token_revoked");
f.listToolsForNamedGateway.mockRejectedValue(error);
await expect(createAssignedMcpTools(f)).rejects.toBe(error);
});
it("rejects error payloads and incomplete outcomes, and accepts successful replay", async () => {
const f = fixture([descriptor("recall")]);
const assigned = await createAssignedMcpTools(f);
const call = { tool: assigned.definitions()[0]!.name as string, arguments: {} };
f.executeTool.mockResolvedValueOnce({ status: "completed", result: { isError: true, content: [] } });
await expect(assigned.execute(call)).rejects.toMatchObject({ reasonCode: "tool_error" });
f.executeTool.mockResolvedValueOnce({ status: "pending", result: {} });
await expect(assigned.execute(call)).rejects.toThrow("assigned_mcp_tool_execution_incomplete");
f.executeTool.mockResolvedValueOnce({ status: "replayed", result: { memory: "synthetic" } });
await expect(assigned.execute(call)).resolves.toEqual({ memory: "synthetic" });
});
});