diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index ba57a1126b..c643b88cd3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -742,6 +742,19 @@ requirements. Compare the union of group names with `tools/list` when checking that a client received the full catalog. An operation absent from all groups is missing from the complete inventory; it is not an implicit unavailable entry. +Availability also reflects the active provider client's dispatched requests. A +refusal or provider failure observed for an operation keeps that operation +unavailable until a request for it dispatched later succeeds. A provider adapter +or process failure also marks that provider's other declared-available +operations unavailable until any request to that provider dispatched later +succeeds; their remediation states that the status was inferred rather than +observed for them. Recovery is not proof that each operation works, and failures +observed on other operations or providers remain. Outcomes are ordered by +dispatch to the provider client rather than reply completion, so a late reply +from an older request does not override a newer outcome. Request deadlines and +cancellations neither mark nor clear availability. A new provider client starts +without these outcomes. + `detail` selects the projection and the result echoes it. The default `summary` keeps open state, target identity, the binding, current-route `capabilities`, run, activity and health status, and each provider candidate's diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index b73761a502..75fccdc330 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -49,6 +49,7 @@ import { InvestigationRecords } from "../investigation/InvestigationRecords.js"; import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js"; import { BinarySessionRecords } from "./BinarySessionRecords.js"; import { binarySessionStatus } from "./BinarySessionStatus.js"; +import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js"; import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js"; import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js"; import { @@ -197,10 +198,7 @@ export class BinarySession readonly #bundleResourceFactory: BundleResourceClientFactory | undefined; #activeBundleResources: ActiveBundleResourceSession | undefined; #pendingBundleResourceCleanup: ActiveBundleResourceSession[] = []; - readonly #runtimeUnavailability = new Map< - string, - { readonly reason: string } - >(); + readonly #runtimeAvailability = new SessionRuntimeAvailability(); readonly #availabilityListeners = new Set<() => void | Promise>(); constructor( @@ -1252,7 +1250,7 @@ export class BinarySession }), route: this.#currentRoute(), router: this.#providerRouter, - runtimeUnavailability: this.#runtimeUnavailability, + runtimeUnavailability: this.#runtimeAvailability.entries, runId: run?.runId, runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [], requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [], @@ -1414,6 +1412,7 @@ export class BinarySession // A failed reply cannot prove that a dispatched metadata write rolled back. // Stop replay before dispatch, including reads overlapping the write. if (capability.effects.mutatesArtifact) this.invalidateSnapshot(); + const dispatch = this.#runtimeAvailability.dispatch(); const call = active.client.execute(name, arguments_, options); const result = unrecordedExecution(await call); const profiled = bindExecutionTarget( @@ -1442,7 +1441,15 @@ export class BinarySession this.#active === active ) this.#active.nativeSourceAdmitted = true; - this.#observeRuntimeAvailability(name, execution); + if ( + this.#runtimeAvailability.observe( + dispatch, + capability, + active.route.capabilities.values(), + execution, + ) + ) + this.#emitAvailabilityChanged(); // Source facts belong to the observation, even when this query cannot be // replayed (for example a Ghidra procedure read without a document selector). if (execution.ok && execution.value.sourceEvidence !== undefined) { @@ -1486,56 +1493,8 @@ export class BinarySession return execution; } - #observeRuntimeAvailability( - operation: AnalysisOperation, - result: Result, - ): void { - if (result.ok) { - if (this.#markRuntimeAvailable(operation)) - this.#emitAvailabilityChanged(); - return; - } - if (result.error._tag === "AnalysisCapabilityUnavailableError") { - if (this.#markRuntimeUnavailable(operation, result.error.message)) - this.#emitAvailabilityChanged(); - return; - } - if ( - [ - "ProviderAdapterError", - "HopperProcessError", - "HopperStartError", - ].includes(result.error._tag) - ) { - const capabilities = this.#active?.route.capabilities; - const providerId = capabilities?.get(operation)?.provider.id; - let changed = false; - for (const descriptor of capabilities?.values() ?? []) - if (providerId !== undefined && descriptor.provider.id === providerId) - changed = - this.#markRuntimeUnavailable( - descriptor.operation, - `${result.error._tag}: ${result.error.message}`, - ) || changed; - if (changed) this.#emitAvailabilityChanged(); - } - } - - #markRuntimeAvailable(operation: string): boolean { - return this.#runtimeUnavailability.delete(operation); - } - - #markRuntimeUnavailable(operation: string, reason: string): boolean { - const current = this.#runtimeUnavailability.get(operation); - if (current?.reason === reason) return false; - this.#runtimeUnavailability.set(operation, { reason }); - return true; - } - #clearRuntimeAvailability(): void { - if (this.#runtimeUnavailability.size === 0) return; - this.#runtimeUnavailability.clear(); - this.#emitAvailabilityChanged(); + if (this.#runtimeAvailability.clear()) this.#emitAvailabilityChanged(); } #emitAvailabilityChanged(): void { diff --git a/src/application/binary/SessionRuntimeAvailability.ts b/src/application/binary/SessionRuntimeAvailability.ts new file mode 100644 index 0000000000..7519865a4b --- /dev/null +++ b/src/application/binary/SessionRuntimeAvailability.ts @@ -0,0 +1,169 @@ +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; +import type { CapabilityDescriptor } from "../AnalysisProvider.js"; + +/** Failures of the provider adapter or process rather than of one request. */ +const PROVIDER_WIDE_FAILURES: ReadonlySet = new Set([ + "ProviderAdapterError", + "HopperProcessError", + "HopperStartError", +]); + +interface Failure { + readonly dispatch: number; + readonly reason: string; +} + +interface ProviderFailure extends Failure { + readonly operation: string; +} + +/** Newest failure and newest success, both by dispatch, for one owner. */ +interface Outcomes { + failure: F | undefined; + succeeded: number; +} + +type RoutedOperation = Pick< + CapabilityDescriptor, + "operation" | "provider" | "available" +>; + +/** Reason recorded for an operation's own refusal or provider-wide failure. */ +export const runtimeFailureReason = ( + failure: AnalysisError, +): string | undefined => { + if (failure._tag === "AnalysisCapabilityUnavailableError") + return failure.message; + return PROVIDER_WIDE_FAILURES.has(failure._tag) + ? `${failure._tag}: ${failure.message}` + : undefined; +}; + +/** Explain unavailability inferred for an operation no request failed for. */ +export const inferredUnavailabilityReason = ( + providerId: string, + failure: Pick, +): string => + `Inferred from the ${failure.operation} failure (${failure.reason}). Retry after repairing the provider; a later successful ${providerId} request clears this.`; + +/** + * Own the runtime unavailability overlaid on the active route's declared + * capabilities. + * + * An operation is unavailable while its newest refusal or provider-wide + * failure was dispatched after its newest success. A declared-available + * operation is also unavailable, as inferred, while its provider's newest + * provider-wide failure was dispatched after any success from that provider. + * One success therefore returns inferred siblings to their declared + * availability without proving each is healthy, and keeps failures observed + * for other operations. + * + * Only the newest dispatch of each outcome matters, so the projection does not + * depend on the order in which overlapping replies complete. Dispatch is the + * latest order this session owns; it does not establish how a provider + * interleaves overlapping requests. + */ +export class SessionRuntimeAvailability { + readonly #operations = new Map>(); + readonly #providers = new Map>(); + #projected: ReadonlyMap = new Map(); + #dispatched = 0; + + /** Current runtime unavailability keyed by operation. */ + get entries(): ReadonlyMap { + return this.#projected; + } + + /** Order one request immediately before it reaches the provider client. */ + dispatch(): number { + this.#dispatched += 1; + return this.#dispatched; + } + + /** + * Record one dispatched request's outcome against the route it ran on and + * report whether visible availability changed. + */ + observe( + dispatch: number, + capability: RoutedOperation, + route: Iterable, + result: Result, + ): boolean { + const operation = outcomes(this.#operations, capability.operation); + const provider = outcomes(this.#providers, capability.provider.id); + if (result.ok) { + operation.succeeded = Math.max(operation.succeeded, dispatch); + provider.succeeded = Math.max(provider.succeeded, dispatch); + // A success can only remove unavailability. + if (this.#projected.size === 0) return false; + } else { + const reason = runtimeFailureReason(result.error); + if (reason === undefined) return false; + operation.failure = newer(operation.failure, { dispatch, reason }); + if (PROVIDER_WIDE_FAILURES.has(result.error._tag)) + provider.failure = newer(provider.failure, { + dispatch, + reason, + operation: capability.operation, + }); + } + return this.#project(route); + } + + /** Forget runtime outcomes when the active provider client is replaced. */ + clear(): boolean { + this.#operations.clear(); + this.#providers.clear(); + if (this.#projected.size === 0) return false; + this.#projected = new Map(); + return true; + } + + #project(route: Iterable): boolean { + const projected = new Map(); + for (const descriptor of route) { + const reason = this.#unavailableReason(descriptor); + if (reason !== undefined) projected.set(descriptor.operation, { reason }); + } + const changed = + projected.size !== this.#projected.size || + [...projected].some( + ([operation, { reason }]) => + this.#projected.get(operation)?.reason !== reason, + ); + this.#projected = projected; + return changed; + } + + #unavailableReason(descriptor: RoutedOperation): string | undefined { + const operation = this.#operations.get(descriptor.operation); + if ( + operation?.failure !== undefined && + operation.failure.dispatch > operation.succeeded + ) + return operation.failure.reason; + // A declared refusal already explains its operation and is never dispatched. + if (!descriptor.available) return undefined; + const provider = this.#providers.get(descriptor.provider.id); + return provider?.failure !== undefined && + provider.failure.dispatch > provider.succeeded + ? inferredUnavailabilityReason(descriptor.provider.id, provider.failure) + : undefined; + } +} + +const outcomes = ( + owners: Map>, + key: string, +): Outcomes => { + const existing = owners.get(key); + if (existing !== undefined) return existing; + const created: Outcomes = { failure: undefined, succeeded: 0 }; + owners.set(key, created); + return created; +}; + +const newer = (current: F | undefined, next: F): F => + current !== undefined && current.dispatch > next.dispatch ? current : next; diff --git a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts index 6686c95868..929379e5d4 100644 --- a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts +++ b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts @@ -1,14 +1,33 @@ -import { expect, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; +import { + createAnalysisExecution, + type AnalysisExecution, + type AnalysisOperation, + type AnalysisProvider, + type CapabilityDescriptor, + type ProviderIdentity, +} from "../../../src/application/AnalysisProvider.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; -import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisTimeoutError, +} from "../../../src/domain/analysisErrorCore.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { + inferredUnavailabilityReason, + runtimeFailureReason, +} from "../../../src/application/binary/SessionRuntimeAvailability.js"; import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; -import { err, ok } from "../../../src/domain/result.js"; +import { err, ok, type Result } from "../../../src/domain/result.js"; import { silentLogger } from "../../../src/logger.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createBinarySessionTargets, @@ -309,3 +328,529 @@ it("keeps provider tools available after one request reaches its deadline", asyn await close(); } }); + +type Reply = Result; +type Responder = (operation: string) => Promise; + +const FIXTURE = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +} as const satisfies ProviderIdentity; +const AUXILIARY = { + id: "fixture-auxiliary", + name: "Auxiliary fixture provider", + version: "1", +} as const satisfies ProviderIdentity; + +const liveCapability = ( + provider: ProviderIdentity, + operation: Exclude, + declaredRefusal?: string, +): CapabilityDescriptor => ({ + provider, + operation, + ...(declaredRefusal === undefined + ? { available: true as const, reason: null } + : { available: false as const, reason: declaredRefusal }), + cachePolicy: "live", + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], +}); + +const adapterFailure = (provider: ProviderIdentity, operation: string) => + Promise.resolve(err(new ProviderAdapterError(provider.id, operation))); +const recordedReason = (failure: AnalysisError): string => { + const reason = runtimeFailureReason(failure); + if (reason === undefined) throw new Error(`${failure._tag} is not recorded`); + return reason; +}; +// Compare remediation with its owning formatters; the facts are their inputs. +const observedFailure = (operation: string, provider: string = FIXTURE.id) => + recordedReason(new ProviderAdapterError(provider, operation)); +const inferredFailure = (operation: string, provider: string = FIXTURE.id) => + inferredUnavailabilityReason(provider, { + operation, + reason: observedFailure(operation, provider), + }); +const refusal = (operation: string, reason: string) => + recordedReason( + new AnalysisCapabilityUnavailableError(FIXTURE.id, operation, reason), + ); + +/** Hold provider replies; release and join every launched call. */ +const heldCalls = ( + client: Awaited>["client"], +) => { + const held = new Map< + string, + { readonly reply: Promise; readonly received: () => void } + >(); + const launched: { + readonly release: (reply: Reply) => void; + readonly response: Promise; + }[] = []; + return { + /** Serve a held reply when one is registered for this operation. */ + respond: (operation: string): Promise | undefined => { + const entry = held.get(operation); + if (entry === undefined) return undefined; + held.delete(operation); + entry.received(); + return entry.reply; + }, + dispatch: async (name: string, arguments_: Record) => { + const reply = createDeferred(); + const received = createDeferred(); + held.set(name, { reply: reply.promise, received: received.resolve }); + const response = client.callTool({ name, arguments: arguments_ }); + launched.push({ release: reply.resolve, response }); + await awaitAcknowledgement(received.promise, response, name); + return { + release: reply.resolve, + isError: async () => (await response).isError === true, + }; + }, + /** Release every held reply and wait for its MCP response. */ + join: async () => { + for (const { release } of launched) release(observed("released")); + await Promise.allSettled(launched.map(({ response }) => response)); + }, + }; +}; + +/** Connect a deep fixture and an auxiliary provider with scripted replies. */ +const connectScripted = async (options: { + readonly operations: readonly Exclude[]; + readonly auxiliaryOperations?: readonly Exclude< + AnalysisOperation, + "health" + >[]; + readonly respond: { current: Responder }; + readonly respondAuxiliary?: { current: Responder }; + /** Operations the fixture declares unavailable, with their declared reason. */ + readonly declaredRefusals?: Readonly>; + readonly families?: string; +}) => { + const [path] = await createBinarySessionTargets(); + const provider: AnalysisProvider = { + identity: () => FIXTURE, + resolveAnalysisProfile: () => + Promise.resolve( + ok({ profile: createAnalysisProfile(FIXTURE, { fixture: true }) }), + ), + capabilities: () => + options.operations.map((operation) => + liveCapability( + FIXTURE, + operation, + options.declaredRefusals?.[operation], + ), + ), + createClient: () => ({ + execute: (operation) => + operation === "health" + ? Promise.resolve(observed(null)) + : options.respond.current(operation), + close: () => Promise.resolve(ok(null)), + }), + }; + const auxiliary: AnalysisProvider = { + identity: () => AUXILIARY, + capabilities: () => + (options.auxiliaryOperations ?? []).map((operation) => + liveCapability(AUXILIARY, operation), + ), + createClient: () => ({ + execute: (operation) => + options.respondAuxiliary?.current(operation) ?? + Promise.resolve(ok(createAnalysisExecution(null, AUXILIARY))), + close: () => Promise.resolve(ok(null)), + }), + }; + const session = createTestBinarySession(provider, { + auxiliaryProviders: [auxiliary], + }); + const { client, close } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "provider-recovery", version: "1" }, + serverOptions: { + environment: + options.families === undefined + ? {} + : { REA_MCP_TOOL_FAMILIES: options.families }, + logger: silentLogger, + }, + }); + onTestFinished(close); + expect( + (await client.callTool({ name: "open_binary", arguments: { path } })) + .isError, + ).not.toBe(true); + const call = async (name: string, arguments_: Record) => + (await client.callTool({ name, arguments: arguments_ })).isError === true + ? "error" + : "ok"; + const tools = async () => { + const reply = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + return expandToolAvailability( + toolContract("binary_session").outputSchema.parse(reply.structuredContent) + .result.tool_availability, + ); + }; + const availability = async (names: readonly string[]) => { + const advertised = await tools(); + return Object.fromEntries( + names.map((name) => { + const tool = advertised.find((entry) => entry.name === name); + if (tool === undefined) throw new Error(`${name} is not advertised`); + return [name, tool.available ? "available" : tool.remediation]; + }), + ); + }; + return { client, call, availability, tools }; +}; + +const DIRECT_OPERATIONS = [ + "address_name", + "current_address", + "list_procedures", + "procedure_assembly", + "procedure_info", +] as const; +const ADDRESS = { address: "0x1000", document: "fixture" }; +const PROCEDURE = { procedure: "main" }; + +it("returns provider-inferred siblings to declared availability after one success", async () => { + let broken = false; + const respond = { + current: (operation: string): Promise => { + if (operation === "procedure_info") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "fixture project is read-only", + ), + ), + ); + if (broken) return adapterFailure(FIXTURE, operation); + return Promise.resolve(observed("fixture-text")); + }, + }; + const declared = "Fixture project was opened read-only."; + const { call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "set_comment"], + auxiliaryOperations: ["list_documents"], + declaredRefusals: { set_comment: declared }, + respond, + }); + const refused = refusal("procedure_info", "fixture project is read-only"); + expect(await call("procedure_info", PROCEDURE)).toBe("error"); + broken = true; + expect(await call("address_name", ADDRESS)).toBe("error"); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + const names = [...DIRECT_OPERATIONS, "set_comment", "list_documents"]; + expect(await availability(names)).toEqual({ + address_name: observedFailure("address_name"), + current_address: inferredFailure("procedure_assembly"), + list_procedures: inferredFailure("procedure_assembly"), + procedure_assembly: observedFailure("procedure_assembly"), + // Separately observed and declared refusals keep their own reasons. + procedure_info: refused, + set_comment: declared, + list_documents: "available", + }); + + broken = false; + expect(await call("address_name", ADDRESS)).toBe("ok"); + // One success is evidence the provider answers again, not that every + // operation is healthy: failures observed on other operations remain. + expect(await availability(names)).toEqual({ + address_name: "available", + current_address: "available", + list_procedures: "available", + procedure_assembly: observedFailure("procedure_assembly"), + procedure_info: refused, + set_comment: declared, + list_documents: "available", + }); + expect(await call("procedure_assembly", PROCEDURE)).toBe("ok"); + expect(await availability(["procedure_assembly", "procedure_info"])).toEqual({ + procedure_assembly: "available", + procedure_info: refused, + }); +}); + +it("keeps each provider's failures and ignores cancelled requests", async () => { + let fixtureBroken = false; + let auxiliaryBroken = true; + const respond = { + current: (operation: string): Promise => { + if (operation === "list_procedures") + return Promise.resolve(err(new AnalysisCancelledError(operation))); + return fixtureBroken + ? adapterFailure(FIXTURE, operation) + : Promise.resolve(observed("fixture-text")); + }, + }; + const respondAuxiliary = { + current: (operation: string): Promise => + auxiliaryBroken + ? adapterFailure(AUXILIARY, operation) + : Promise.resolve(ok(createAnalysisExecution("doc", AUXILIARY))), + }; + const { call, availability } = await connectScripted({ + operations: ["address_name", "current_address", "list_procedures"], + auxiliaryOperations: ["current_document", "list_documents"], + respond, + respondAuxiliary, + }); + const names = [ + "address_name", + "current_address", + "list_procedures", + "current_document", + "list_documents", + ]; + expect(await call("list_documents", {})).toBe("error"); + expect(await call("address_name", ADDRESS)).toBe("ok"); + const auxiliaryFailure = { + current_document: inferredFailure("list_documents", AUXILIARY.id), + list_documents: observedFailure("list_documents", AUXILIARY.id), + }; + // Another provider's success does not clear the auxiliary provider's state. + expect(await availability(names)).toEqual({ + address_name: "available", + current_address: "available", + list_procedures: "available", + ...auxiliaryFailure, + }); + + fixtureBroken = true; + expect(await call("address_name", ADDRESS)).toBe("error"); + // A cancelled request is not evidence that the provider recovered. + expect(await call("list_procedures", {})).toBe("error"); + const fixtureFailure = { + address_name: observedFailure("address_name"), + current_address: inferredFailure("address_name"), + list_procedures: inferredFailure("address_name"), + }; + expect(await availability(names)).toEqual({ + ...fixtureFailure, + ...auxiliaryFailure, + }); + + auxiliaryBroken = false; + expect(await call("current_document", {})).toBe("ok"); + expect(await availability(names)).toEqual({ + ...fixtureFailure, + current_document: "available", + list_documents: auxiliaryFailure.list_documents, + }); +}); + +it("keeps newer failures when older successes complete after them", async () => { + let reply: Responder = () => Promise.resolve(observed("text")); + const respond = { current: (operation: string) => reply(operation) }; + const { client, call, availability } = await connectScripted({ + operations: [...DIRECT_OPERATIONS, "current_procedure"], + respond, + }); + const calls = heldCalls(client); + respond.current = (operation) => calls.respond(operation) ?? reply(operation); + const document = { document: "doc" }; + const refuse: Responder = (operation) => + Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "fixture refusal", + ), + ), + ); + try { + // Successes dispatched before an own refusal, before a provider failure, + // or between the two complete after both. + const olderAddress = await calls.dispatch("address_name", ADDRESS); + const olderCurrent = await calls.dispatch("current_address", document); + reply = refuse; + expect(await call("address_name", ADDRESS)).toBe("error"); + expect(await call("current_procedure", document)).toBe("error"); + const between = await calls.dispatch("current_procedure", document); + reply = (operation) => adapterFailure(FIXTURE, operation); + expect(await call("procedure_assembly", PROCEDURE)).toBe("error"); + olderAddress.release(observed("old-name")); + olderCurrent.release(observed("0x1000")); + between.release(observed("main")); + for (const settled of [olderAddress, olderCurrent, between]) + expect(await settled.isError()).toBe(false); + expect( + await availability([ + "address_name", + "current_address", + "current_procedure", + "list_procedures", + ]), + ).toEqual({ + address_name: refusal("address_name", "fixture refusal"), + current_address: inferredFailure("procedure_assembly"), + // Its own later success superseded the refusal, not the provider failure. + current_procedure: inferredFailure("procedure_assembly"), + list_procedures: inferredFailure("procedure_assembly"), + }); + + // A success dispatched behind a failure executed after it, even though + // the failure was not yet observed when the success was dispatched. + reply = () => Promise.resolve(observed("text")); + const failure = await calls.dispatch("procedure_assembly", PROCEDURE); + const queued = await calls.dispatch("current_address", document); + failure.release( + err(new ProviderAdapterError(FIXTURE.id, "procedure_assembly")), + ); + expect(await failure.isError()).toBe(true); + queued.release(observed("0x1000")); + expect(await queued.isError()).toBe(false); + expect( + await availability([ + "address_name", + "current_address", + "current_procedure", + "list_procedures", + "procedure_assembly", + ]), + ).toEqual({ + address_name: refusal("address_name", "fixture refusal"), + current_address: "available", + current_procedure: "available", + list_procedures: "available", + procedure_assembly: observedFailure("procedure_assembly"), + }); + } finally { + await calls.join(); + } +}); + +it("keeps newer successes when an older failure completes last", async () => { + const respond = { + current: (_operation: string): Promise => + Promise.resolve(observed("text")), + }; + const { client, call, availability } = await connectScripted({ + operations: DIRECT_OPERATIONS, + respond, + }); + const calls = heldCalls(client); + respond.current = (operation) => + calls.respond(operation) ?? Promise.resolve(observed("text")); + const names = ["address_name", "current_address", "list_procedures"]; + try { + // A sibling dispatched later already proved the provider answers. + const older = await calls.dispatch("address_name", ADDRESS); + const newer = await calls.dispatch("current_address", { document: "doc" }); + newer.release(observed("0x1000")); + expect(await newer.isError()).toBe(false); + older.release(err(new ProviderAdapterError(FIXTURE.id, "address_name"))); + expect(await older.isError()).toBe(true); + expect(await availability(names)).toEqual({ + address_name: observedFailure("address_name"), + current_address: "available", + list_procedures: "available", + }); + + // A later success of the same operation supersedes its older failure. + const stale = await calls.dispatch("procedure_assembly", PROCEDURE); + expect(await call("procedure_assembly", PROCEDURE)).toBe("ok"); + stale.release( + err(new ProviderAdapterError(FIXTURE.id, "procedure_assembly")), + ); + expect(await stale.isError()).toBe(true); + expect(await availability([...names, "procedure_assembly"])).toEqual({ + address_name: observedFailure("address_name"), + current_address: "available", + list_procedures: "available", + procedure_assembly: "available", + }); + } finally { + await calls.join(); + } +}); + +it("restores composed tools in a scoped catalog after one composed success", async () => { + let broken = true; + const respond = { + current: (operation: string): Promise => { + if (broken) return adapterFailure(FIXTURE, operation); + if (operation === "procedure_address") + return Promise.resolve(observed("0x1")); + if (operation === "address_name") + return Promise.resolve(observed("fixture_name")); + return Promise.resolve(observed("return 0;")); + }, + }; + const { client, availability, tools } = await connectScripted({ + operations: [ + "address_name", + "procedure_address", + "procedure_callees", + "procedure_callers", + "procedure_pseudo_code", + "search_procedures", + "search_strings", + "xrefs", + ], + respond, + families: "session,enhanced", + }); + // Direct prerequisites are outside this scope; only composed tools can retry. + const batchItemStatus = async () => { + const reply = await client.callTool({ + name: "batch_decompile", + arguments: { addresses: ["0x1"] }, + }); + expect(reply.isError).not.toBe(true); + return reply.structuredContent; + }; + expect(await batchItemStatus()).toMatchObject({ + normalized_result: { items: [{ status: "error" }] }, + }); + expect(await availability(["trace_feature", "get_call_graph"])).toEqual({ + // The batch still decompiles the unresolved selector; its failure is newest. + trace_feature: inferredFailure("procedure_pseudo_code"), + get_call_graph: observedFailure("procedure_address"), + }); + + broken = false; + expect(await batchItemStatus()).toMatchObject({ + normalized_result: { items: [{ status: "ok", pseudocode: "return 0;" }] }, + }); + expect( + await availability(["batch_decompile", "trace_feature", "get_call_graph"]), + ).toEqual({ + batch_decompile: "available", + trace_feature: "available", + get_call_graph: "available", + }); + expect( + (await tools()).find(({ name }) => name === "get_call_graph"), + ).toMatchObject({ + default_mode_available: true, + modes: [ + { name: "forward", available: true, missing_operations: [] }, + { name: "backward", available: true, missing_operations: [] }, + ], + }); +});