fix: align aggregate tools with session capabilities

This commit is contained in:
morluto 2026-07-29 18:18:31 +00:00
parent f3b3fb07de
commit ede91ff1f0
11 changed files with 110 additions and 14 deletions

View File

@ -671,10 +671,10 @@
"mcp_resource_templates": 11
},
"digests": {
"tools_sha256": "daef46f5c6af3e04bf5adde36468da9a87c862057f8e64ff423b22028f5680c4",
"tools_sha256": "58bc4f72c5f8de4328728ead9f3a28ec5548484b7b9cf2ed85cf0edcc689a63c",
"prompts_sha256": "3886a3bf55a448def79e3eb429e6a56eb2ba075423c5cb1e5747642b389ef8fe",
"resources_sha256": "e0e872276ebf6f88e916ef24930a99eed27ce4c93e494e60820e9bd8cc8497c3",
"combined_sha256": "5b09f9fa9e75e9cd520fa405cb869e1c6bdbbcc576f52d5a334482975ec7b1fe",
"combined_sha256": "e83a177f5eca63bbad17886a61f5a1a376d44afe862c51c56e5d38339f7d41f8",
"providers_sha256": "918ccd6b8ae442cd5844dcfde55086f458176241c8d166c1e9dd0f242ccd2bb0"
}
}

View File

@ -1,6 +1,6 @@
{
"schema_version": 1,
"manifest_id": "ecm_9571e8e6cbe76192d3d6b2b0f3f039aa72d13c45c93278510abf8db4951a8aa8",
"manifest_id": "ecm_c1609d2cc959477e200df1a368ade5e3d68ad6fd4041009b78a5778a3b76e9bc",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -14,7 +14,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "30e1c7251836d3c9d32d2dc17f5813320b36c22ea4f2516f2c8ee2022d1f76cd"
"sha256": "1398947e9119a5a9af08514413d8cfdd8dcdc9373ebab1ba6eb525126db5ffff"
}
],
"claims": [

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 114
catalog_digest: "5b09f9fa9e75e9cd520fa405cb869e1c6bdbbcc576f52d5a334482975ec7b1fe"
catalog_digest: "e83a177f5eca63bbad17886a61f5a1a376d44afe862c51c56e5d38339f7d41f8"
---
# REA

View File

@ -3,9 +3,9 @@ import type {
AnalysisOperation,
AnalysisOperationPort,
} from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/errors.js";
import { AnalysisOutputError, type AnalysisError } from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { ok, type Result } from "../domain/result.js";
import { err, ok, type Result } from "../domain/result.js";
type Facet =
| { readonly state: "available"; readonly value: JsonValue }
@ -83,7 +83,9 @@ export const inspectAddressContext = async (
input: { readonly address: string; readonly document?: string | undefined },
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisError>> => {
const args = parameters(input.document, input.address);
const document = await resolveDocument(analysis, input.document, signal);
if (!document.ok) return document;
const args = parameters(document.value, input.address);
const operations = [
"address_name",
"resolve_containing_procedure",
@ -95,7 +97,7 @@ export const inspectAddressContext = async (
operations.map((operation) =>
analysis.execute(
operation,
operation === "list_bookmarks" ? parameters(input.document) : args,
operation === "list_bookmarks" ? parameters(document.value) : args,
executionOptions(signal),
),
),
@ -110,7 +112,7 @@ export const inspectAddressContext = async (
const bookmarks = facet(results[4], operations[4]);
return ok({
address: input.address,
document: input.document ?? null,
document: document.value,
name,
procedure,
comment,
@ -128,6 +130,28 @@ export const inspectAddressContext = async (
});
};
const resolveDocument = async (
analysis: AnalysisOperationPort,
document: string | undefined,
signal: AbortSignal | undefined,
): Promise<Result<string, AnalysisError>> => {
if (document !== undefined) return ok(document);
const current = await analysis.execute(
"current_document",
{},
executionOptions(signal),
);
if (!current.ok) return current;
return typeof current.value.result === "string"
? ok(current.value.result)
: err(
new AnalysisOutputError(
"current_document",
"expected a document identity string",
),
);
};
const facet = (
result: Result<AnalysisExecution, AnalysisError> | undefined,
operation: AnalysisOperation,

View File

@ -111,6 +111,16 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
],
};
const SESSION_COMPOSITION_REQUIREMENTS: Readonly<
Record<string, readonly string[]>
> = {
get_navigation_context: [
"current_document",
"current_address",
"resolve_containing_procedure",
],
};
/** Build stable per-operation availability for discovery and tool visibility. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
@ -185,6 +195,9 @@ const clientRequirementsFor = (
};
const availabilityFor = (context: AvailabilityContext): Availability => {
const sessionComposition = SESSION_COMPOSITION_REQUIREMENTS[context.name];
if (sessionComposition !== undefined)
return composedAvailabilityFor(sessionComposition, context.descriptors);
const javascriptApplication = javascriptApplicationAvailability(context);
if (javascriptApplication !== null) return javascriptApplication;
const policyDecision = policyAvailability(context);
@ -366,6 +379,13 @@ const composedAvailability = (
reason: "provider_missing",
remediation: "No provider composition is declared for this operation.",
};
return composedAvailabilityFor(requirements, descriptors);
};
const composedAvailabilityFor = (
requirements: readonly string[],
descriptors: ReadonlyMap<string, ProviderDescriptor>,
): Availability => {
const missing = requirements.find((operation) => !descriptors.has(operation));
if (missing !== undefined)
return {

View File

@ -500,7 +500,7 @@ export const SESSION_TOOL_CONTRACTS = [
),
session(
"list_unknowns",
"List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.",
"List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Pages default to 100 items; while has_more is true, pass next_offset as offset and continue until false. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.",
listUnknownsInputSchema,
),
session(

File diff suppressed because one or more lines are too long

View File

@ -302,6 +302,12 @@ export const registerUnknownTools = ({
);
if (!parsed.ok) return toCallToolResult(parsed, updateContract);
const result = session.updateUnknown(parsed.value);
if (result.ok)
void server.server
.sendResourceUpdated({
uri: `rea://unknown/${result.value.unknown_id}`,
})
.catch(() => undefined);
return result.ok
? toCallToolResult(result, updateContract, {
resourceLinks: [

View File

@ -94,6 +94,10 @@ describe("analysis context queries", () => {
const bookmarkInputs: Readonly<Record<string, unknown>>[] = [];
const analysis: AnalysisOperationPort = {
execute: (operation, input) => {
if (operation === "current_document")
return Promise.resolve(
ok(createAnalysisExecution("fixture", provider)),
);
if (operation === "comment")
return Promise.resolve(
err(new AnalysisProtocolError("comments unavailable")),
@ -145,6 +149,7 @@ describe("analysis context queries", () => {
expect(result).toMatchObject({
ok: true,
value: {
document: "fixture",
name: { state: "available", value: "entry" },
comment: {
state: "unavailable",
@ -156,7 +161,7 @@ describe("analysis context queries", () => {
},
},
});
expect(bookmarkInputs).toEqual([{}]);
expect(bookmarkInputs).toEqual([{ document: "fixture" }]);
});
it("propagates cancellation instead of degrading it to an unavailable facet", async () => {
@ -171,7 +176,10 @@ describe("analysis context queries", () => {
};
await expect(
inspectAddressContext(analysis, { address: "0x401000" }),
inspectAddressContext(analysis, {
address: "0x401000",
document: "fixture",
}),
).resolves.toEqual(err(cancelled));
});
});

View File

@ -194,4 +194,36 @@ describe("capability inventory", () => {
missing_optional: [],
});
});
it("advertises navigation context only when every cursor operation is available", () => {
const capabilities = [
"current_document",
"current_address",
"resolve_containing_procedure",
].map((operation) => ({
operation,
available: true,
reason: null,
}));
expect(
entry(
"get_navigation_context",
status({ open: true, kind: "executable", capabilities }),
),
).toMatchObject({ available: true, reason: "available" });
expect(
entry(
"get_navigation_context",
status({
open: true,
kind: "executable",
capabilities: capabilities.slice(0, 2),
}),
),
).toMatchObject({
available: false,
reason: "provider_missing",
remediation: expect.stringContaining("resolve_containing_procedure"),
});
});
});

View File

@ -136,9 +136,14 @@ describe("target-free MCP lifecycle", () => {
});
const mcp = new Client({ name: "session-test", version: "1.0.0" });
let resourceListChanges = 0;
let unknownResourceUpdates = 0;
mcp.setNotificationHandler("notifications/resources/list_changed", () => {
resourceListChanges += 1;
});
mcp.setNotificationHandler("notifications/resources/updated", (notice) => {
if (notice.params.uri.startsWith("rea://unknown/"))
unknownResourceUpdates += 1;
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server);
@ -297,6 +302,7 @@ describe("target-free MCP lifecycle", () => {
});
expect(resolved.isError).not.toBe(true);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(unknownResourceUpdates).toBe(1);
expect(resourceListChanges).toBe(changesBeforeMutation);
expect(
structured(