From 76b56957e94d804667f7890b244639ecc4725b32 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:12:42 +0800 Subject: [PATCH 1/2] refactor(android): own search summaries at the producer boundary Reuse validated class pages for inline pagination audits while retaining complete provider replies. Move retention into the shared Android workflow and remove the MCP-only service wrapper and application text reparser. Preserve unknown JSON metadata, late cancellation and retention failures. --- src/android/JadxAnalysis.ts | 19 +- src/android/JadxProtocol.ts | 56 +++-- src/android/JadxSession.ts | 118 +++++++--- .../android/AndroidAnalysisPort.ts | 8 +- .../android/AndroidAnalysisService.ts | 20 +- .../android/AndroidSearchEvidenceSummary.ts | 75 +----- src/server/createServer.ts | 2 +- src/server/registerAndroidTools.ts | 20 +- tests/boundary/android/JadxAnalysis.test.ts | 27 ++- tests/boundary/android/jadxProtocol.test.ts | 47 +++- .../androidSearchEvidenceBudget.test.ts | 215 +++++++++++++++--- .../android/androidLateCompletion.test.ts | 31 ++- tests/fixtures/android/jadx-mcp.mjs | 23 +- 13 files changed, 452 insertions(+), 209 deletions(-) diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 6b623d5955..73852df970 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -8,7 +8,7 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import { jadxAppSchema, - jadxClassPageSchema, + type JadxClassPage, jadxClassSchema, jadxLoadSchema, jadxMethodSchema, @@ -18,6 +18,11 @@ import { /** The adapter consumes tool payloads, never provider RPC details outside this layer. */ export interface JadxToolPort { + listClasses(input: { + readonly query: string; + readonly offset: number; + readonly limit: number; + }): Promise; json( name: string, input: Readonly>, @@ -138,13 +143,11 @@ const searchClasses = async ( const pageSize = 4096; let offset = 0; do { - const page = jadxClassPageSchema.parse( - await tools.json("list_classes", { - query: request.input.query, - offset, - limit: pageSize, - }), - ); + const page = await tools.listClasses({ + query: request.input.query, + offset, + limit: pageSize, + }); if ( page.total_classes !== loaded.class_count || page.offset !== offset || diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index ec7420c58b..68a689042b 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js"; @@ -61,14 +61,24 @@ export const jadxAppSchema = z.object({ target_sdk: z.string(), permissions: z.array(z.string()), }); -export const jadxClassPageSchema = z.object({ - scope: z.literal("top_level"), - total_classes: count, - next_offset: count, - offset: count, - limit: count, - items: z.array(z.string()), -}); +const jadxClassPageSchema = z + .object({ + scope: z.literal("top_level"), + total_classes: count, + next_offset: count, + offset: count, + limit: count, + items: z.array(z.string()), + }) + .catchall(jsonValueSchema); +export type JadxClassPage = z.infer; + +/** Validate a decoded page without dropping observed JSON members such as __proto__. */ +export const parseJadxClassPage = (value: JsonValue): JadxClassPage => { + jadxClassPageSchema.parse(value); + return value as JadxClassPage; +}; + export const jadxClassSchema = z.object({ full_name: z.string(), name: z.string(), @@ -131,28 +141,30 @@ export const jadxXrefsSchema = z.object({ }), ), }); -const toolResponse = z.object({ - content: z - .array(z.object({ type: z.literal("text"), text: z.string() })) - .length(1), - isError: z.boolean().optional(), -}); +const toolResponse = z + .object({ + content: z.tuple([ + z + .object({ type: z.literal("text"), text: z.string() }) + .catchall(jsonValueSchema), + ]), + isError: z.boolean().exactOptional(), + }) + .catchall(jsonValueSchema); /** Parse the upstream text-only envelope, preserving errors at the producing boundary. */ export const parseJadxEnvelope = ( - response: unknown, + response: JsonValue, operation: AndroidOperation, -): { text: string; failed: boolean } => { +): z.infer => { const parsed = toolResponse.safeParse(response); - if (!parsed.success || parsed.data.content[0] === undefined) + if (!parsed.success) throw new AnalysisOutputError( operation, "JADX returned an unsupported MCP content envelope", ); - return { - text: parsed.data.content[0].text, - failed: parsed.data.isError === true, - }; + // The schema establishes the shape; its object copy would omit __proto__. + return response as z.infer; }; /** Decode an upstream JSON text payload without trusting its contents. */ diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index f99557c9bf..2f2eee0794 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -1,9 +1,7 @@ import { throwIfJadxInterrupted } from "./JadxInterruption.js"; import { Client } from "@modelcontextprotocol/client"; -import { - createAnalysisExecution, - type AnalysisExecution, -} from "../application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../application/AnalysisProvider.js"; +import type { AndroidAnalysisExecution } from "../application/android/AndroidAnalysisPort.js"; import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import type { AndroidPartialObservation } from "../domain/android/androidPartialObservation.js"; import { @@ -13,12 +11,17 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + type JsonValue, +} from "../domain/jsonValue.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js"; import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js"; import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js"; import { + parseJadxClassPage, jadxLoadSchema, parseJadxFailure, jadxRuntimeSchema, @@ -32,6 +35,16 @@ import { JADX_LIMITATIONS, } from "./JadxRelease.js"; +type JadxCall = { + readonly operation: string; + readonly input: Readonly>; + readonly response: JsonValue; +}; +type JadxCallObservation = { + readonly complete: JadxCall; + inline?: JsonValue; +}; + /** One owned engine connection; its wire protocol stays inside the Android adapter. */ export class JadxSession { readonly transport: JadxMcpTransport; @@ -47,11 +60,7 @@ export class JadxSession { readonly bridgeHash: string; } | undefined; - #observedCalls: { - readonly operation: string; - readonly input: Readonly>; - readonly response: JsonValue; - }[] = []; + #observedCalls: JadxCallObservation[] = []; #serverName: string | null = null; #serverVersion: string | null = null; #engineVersion: string | null = null; @@ -73,7 +82,7 @@ export class JadxSession { readonly signal: AbortSignal; /** Selected whole-operation deadline; also bounds each upstream call. */ readonly timeoutMs: number; - }): Promise { + }): Promise { const { request, target, snapshot, jarHash, signal, timeoutMs } = context; this.#partialContext = { operation: request.operation, @@ -147,21 +156,39 @@ export class JadxSession { Math.floor(runtime.max_heap_bytes / (1024 * 1024)) || null, } as const; const result = await analyzeJadxRequest(tools, request, loaded, engine); - return createAnalysisExecution(result, JADX_PROVIDER_IDENTITY, { - rawResult: { - server, - bridge_sha256: context.bridgeHash, - jar_sha256: jarHash, - loaded, - calls: this.#observedCalls, - }, - subject: target, - limitations: JADX_LIMITATIONS, - locations: - request.operation === "inspect_android_package" - ? [{ kind: "artifact-path", path: "AndroidManifest.xml" }] - : [], + const raw = jsonObjectSchema.parse({ + server, + bridge_sha256: context.bridgeHash, + jar_sha256: jarHash, + loaded, }); + const execution = createAnalysisExecution( + result, + JADX_PROVIDER_IDENTITY, + { + rawResult: { + ...raw, + calls: this.#observedCalls.map(({ complete }) => complete), + }, + subject: target, + limitations: JADX_LIMITATIONS, + locations: + request.operation === "inspect_android_package" + ? [{ kind: "artifact-path", path: "AndroidManifest.xml" }] + : [], + }, + ); + return request.operation === "search_android_classes" + ? { + ...execution, + searchRawSummary: { + ...raw, + calls: this.#observedCalls.map( + ({ complete, inline }) => inline ?? complete, + ), + }, + } + : execution; } finally { signal.removeEventListener("abort", abort); } @@ -189,7 +216,7 @@ export class JadxSession { server_version: this.#serverVersion, engine_version: this.#engineVersion, }, - calls: this.#observedCalls, + calls: this.#observedCalls.map(({ complete }) => complete), }; } @@ -216,7 +243,7 @@ export class JadxSession { const call = async ( name: string, input: Readonly>, - ): Promise => { + ) => { throwIfJadxInterrupted(signal); // Keep an explicit bound: omitting it falls back to the SDK's 60-second default. const response = await this.#client.callTool( @@ -225,10 +252,12 @@ export class JadxSession { ); const observedResponse = jsonValueSchema.parse(response); const call = { operation: name, input, response: observedResponse }; - this.#observedCalls.push(call); - const envelope = parseJadxEnvelope(response, request.operation); - if (envelope.failed) { - const failure = parseJadxFailure(envelope.text); + const observation: JadxCallObservation = { complete: call }; + this.#observedCalls.push(observation); + const envelope = parseJadxEnvelope(observedResponse, request.operation); + const text = envelope.content[0].text; + if (envelope.isError === true) { + const failure = parseJadxFailure(text); if (failure !== undefined) { if (name === "load_apk" && failure.kind === "jvm-memory-failure") throw new AnalysisResourceConstraintError( @@ -266,18 +295,37 @@ export class JadxSession { throw new ProviderAdapterError("jadx", request.operation, { diagnostics: { upstream_operation: name, - reason: envelope.text, + reason: text, target_path: target.path, target_sha256: target.sha256, }, }); } - return envelope.text; + return { text, envelope, observation }; }; return { - text: call, + text: async (name, input) => (await call(name, input)).text, json: async (name, input) => - parseJadxJson(await call(name, input), request.operation), + parseJadxJson((await call(name, input)).text, request.operation), + listClasses: async (input) => { + const reply = await call("list_classes", input); + const page = parseJadxClassPage( + parseJadxJson(reply.text, request.operation), + ); + const { items, ...audit } = page; + reply.observation.inline = { + ...reply.observation.complete, + inventory_elided: true, + response: { + ...reply.envelope, + content: reply.envelope.content.map((entry) => ({ + ...entry, + text: JSON.stringify({ ...audit, items_omitted: items.length }), + })), + }, + }; + return page; + }, }; } } diff --git a/src/application/android/AndroidAnalysisPort.ts b/src/application/android/AndroidAnalysisPort.ts index aec02ac9dc..165ea000e1 100644 --- a/src/application/android/AndroidAnalysisPort.ts +++ b/src/application/android/AndroidAnalysisPort.ts @@ -2,12 +2,18 @@ import type { AnalysisExecution, ExecutionOptions, } from "../AnalysisProvider.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; import type { AndroidRequest } from "../../domain/android/androidAnalysis.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Result } from "../../domain/result.js"; import type { ProviderAvailability } from "../AnalysisProvider.js"; +/** Provider-authored inline projection; complete raw observations stay in rawResult. */ +export interface AndroidAnalysisExecution extends AnalysisExecution { + readonly searchRawSummary?: Readonly>; +} + /** Static Android inspection boundary; implementation details belong to the provider. */ export interface AndroidAnalysisPort { /** Inspect the selected JAR and Java runtime without opening an APK. */ @@ -18,5 +24,5 @@ export interface AndroidAnalysisPort { target: BinaryTarget, request: AndroidRequest, options?: ExecutionOptions, - ): Promise>; + ): Promise>; } diff --git a/src/application/android/AndroidAnalysisService.ts b/src/application/android/AndroidAnalysisService.ts index 4e2e5841d4..1f91661415 100644 --- a/src/application/android/AndroidAnalysisService.ts +++ b/src/application/android/AndroidAnalysisService.ts @@ -1,3 +1,5 @@ +import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; +import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js"; import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import { parseBinaryTarget } from "../BinaryTargetResolver.js"; @@ -17,7 +19,10 @@ import { completeProviderEvidence } from "../ProviderOperationExecution.js"; /** Shared CLI/MCP admission, execution and Evidence composition for Android. */ export class AndroidAnalysisService { - constructor(readonly provider: AndroidAnalysisPort) {} + constructor( + readonly provider: AndroidAnalysisPort, + readonly recordEvidence?: EvidenceWriter["recordEvidence"], + ) {} /** Inspect a caller-selected APK and retain producing-provider provenance inline. */ async execute( @@ -44,7 +49,7 @@ export class AndroidAnalysisService { ); if (!executed.ok) return executed; const execution = executed.value; - return completeProviderEvidence( + const completed = completeProviderEvidence( createEvidence( execution.subject === undefined ? target.value @@ -63,5 +68,16 @@ export class AndroidAnalysisService { ), options?.signal, ); + if ( + !completed.ok || + operation !== "search_android_classes" || + execution.searchRawSummary === undefined + ) + return completed; + return summarizeRetainedAndroidSearch( + completed.value, + execution.searchRawSummary, + this.recordEvidence, + ); } } diff --git a/src/application/android/AndroidSearchEvidenceSummary.ts b/src/application/android/AndroidSearchEvidenceSummary.ts index 4db495ece1..9b0f3809f3 100644 --- a/src/application/android/AndroidSearchEvidenceSummary.ts +++ b/src/application/android/AndroidSearchEvidenceSummary.ts @@ -4,66 +4,6 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { ok, type Result } from "../../domain/result.js"; -/** - * Class-search pages duplicate matching names already present in normalized - * results; an empty query also returns the complete top-level inventory. - * Keep the selection and pagination audit inline and link the retained - * record carrying every complete producer reply. - */ -const elideInventoryText = (text: string): string | undefined => { - let page: JsonValue; - try { - page = JSON.parse(text) as JsonValue; - } catch { - return undefined; - } - if ( - typeof page !== "object" || - page === null || - Array.isArray(page) || - !Array.isArray(page.items) - ) - return undefined; - const { items, ...audited } = page; - return JSON.stringify({ ...audited, items_omitted: items.length }); -}; - -/** Replace inventoried page bodies with their pagination audit. */ -const summarizeInventoryCall = (call: JsonValue): JsonValue => { - if ( - typeof call !== "object" || - call === null || - Array.isArray(call) || - call.operation !== "list_classes" - ) - return call; - const response = call.response; - if ( - typeof response !== "object" || - response === null || - Array.isArray(response) || - !Array.isArray(response.content) - ) - return call; - let elided = false; - const content = response.content.map((entry: JsonValue) => { - if ( - typeof entry !== "object" || - entry === null || - Array.isArray(entry) || - typeof entry.text !== "string" - ) - return entry; - const summarized = elideInventoryText(entry.text); - if (summarized === undefined) return entry; - elided = true; - return { ...entry, text: summarized }; - }); - return elided - ? { ...call, inventory_elided: true, response: { ...response, content } } - : call; -}; - /** * Retain the complete search Evidence — full raw provider calls included — * in the session record, then deliver a bounded summary Evidence inline. @@ -72,24 +12,13 @@ const summarizeInventoryCall = (call: JsonValue): JsonValue => { */ export const summarizeRetainedAndroidSearch = ( evidence: Evidence, + summary: Readonly>, recordEvidence: EvidenceWriter["recordEvidence"] | undefined, ): Result => { if (recordEvidence === undefined) return ok(evidence); const recorded = recordEvidence(evidence); if (!recorded.ok) return recorded; const subject = evidence.subject; - const raw = evidence.raw_result; - const summarized: JsonValue = - raw === null || - typeof raw !== "object" || - Array.isArray(raw) || - !Array.isArray(raw.calls) - ? raw - : { - ...raw, - complete_evidence_id: evidence.evidence_id, - calls: raw.calls.map(summarizeInventoryCall), - }; return ok( createEvidence( subject === null @@ -107,7 +36,7 @@ export const summarizeRetainedAndroidSearch = ( operation: evidence.operation, parameters: evidence.parameters, result: evidence.normalized_result, - rawResult: summarized, + rawResult: { ...summary, complete_evidence_id: evidence.evidence_id }, confidence: evidence.confidence, limitations: evidence.limitations, locations: evidence.locations, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 6e647fbbd1..8b4e78f8de 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -892,7 +892,7 @@ const registerConfiguredAnalysisTools = ( ): void => { registerAndroidTools( server, - new AndroidAnalysisService(android), + new AndroidAnalysisService(android, recordEvidence), toolLogger, recordEvidence, withAdmittedAnalysis, diff --git a/src/server/registerAndroidTools.ts b/src/server/registerAndroidTools.ts index a8c29a1873..2caa4c292e 100644 --- a/src/server/registerAndroidTools.ts +++ b/src/server/registerAndroidTools.ts @@ -3,7 +3,6 @@ import type { EvidenceWriter } from "../application/investigation/InvestigationR import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js"; import { inspectAndroidPackage } from "../application/android/AndroidPackageInspection.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { summarizeRetainedAndroidSearch } from "../application/android/AndroidSearchEvidenceSummary.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { Logger } from "pino"; import { @@ -29,23 +28,6 @@ export const registerAndroidTools = ( recordEvidence, withAdmittedAnalysis, ); - // Focused class searches keep complete raw inventory in the retained - // record and deliver a bounded summary inline; the generic handler then - // records and delivers the summary as the search contract's Evidence. - const searchService: ContractToolService<"search_android_classes"> = { - execute: async (operation, input, options) => { - const executed = await service.execute(operation, input, options); - if (!executed.ok) return executed; - return summarizeRetainedAndroidSearch(executed.value, recordEvidence); - }, - }; - const searchHandler = createContractToolHandler( - server, - searchService, - logger, - recordEvidence, - withAdmittedAnalysis, - ); const packageContract = toolContract("inspect_android_package"); const packageService: ContractToolService<"inspect_android_package"> = { execute: (_operation, input, options) => @@ -72,7 +54,7 @@ export const registerAndroidTools = ( server.registerTool( searchContract.name, toolRegistrationOptions(searchContract), - searchHandler(searchContract), + handler(searchContract), ); const classContract = toolContract("inspect_android_class"); server.registerTool( diff --git a/tests/boundary/android/JadxAnalysis.test.ts b/tests/boundary/android/JadxAnalysis.test.ts index 550e6a6526..ccd8bc95b2 100644 --- a/tests/boundary/android/JadxAnalysis.test.ts +++ b/tests/boundary/android/JadxAnalysis.test.ts @@ -50,12 +50,12 @@ it("pages filtered matches independently of the examined inventory and preserves names[4096] = "fixture.TargetDuplicate"; const calls: Record[] = []; const tools: JadxToolPort = { - json: (name, input) => { - expect(name).toBe("list_classes"); + json: () => Promise.reject(new Error("unexpected JSON call")), + listClasses: (input) => { calls.push({ ...input }); const offset = Number(input.offset); return Promise.resolve({ - scope: "top_level", + scope: "top_level" as const, total_classes: 10000, next_offset: offset === 0 ? 8191 : 10000, offset, @@ -94,7 +94,6 @@ it("pages filtered matches independently of the examined inventory and preserves }); it.each([ - { name: "inventory scope", query: "Target", page: { scope: "all_dex" } }, { name: "inventory total", query: "Target", page: { total_classes: 9 } }, { name: "out-of-range cursor", query: "Target", page: { next_offset: 11 } }, { @@ -116,9 +115,10 @@ it.each([ "refuses changed $name instead of reporting complete search coverage", async ({ query, page }) => { const tools: JadxToolPort = { - json: () => + json: () => Promise.reject(new Error("unexpected JSON call")), + listClasses: () => Promise.resolve({ - scope: "top_level", + scope: "top_level" as const, total_classes: 10, next_offset: 10, offset: 0, @@ -151,9 +151,10 @@ it.each([ it("accepts an examined empty inventory without requiring cursor progress", async () => { const tools: JadxToolPort = { - json: () => + json: () => Promise.reject(new Error("unexpected JSON call")), + listClasses: () => Promise.resolve({ - scope: "top_level", + scope: "top_level" as const, total_classes: 0, offset: 0, next_offset: 0, @@ -225,6 +226,8 @@ it("requires explicit direct invoke coverage state in result evidence", () => { it("publishes exact DEX prototypes instead of discarding bridge metadata", async () => { const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: () => Promise.resolve(summary), text: () => Promise.reject(new Error("unexpected text call")), }; @@ -251,6 +254,8 @@ it("publishes exact DEX prototypes instead of discarding bridge metadata", async it("traces only the selected overloaded method and checks the producer identity", async () => { const calls: Record[] = []; const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: (name, input) => { calls.push({ name, ...input }); if (name === "get_class_summary") return Promise.resolve(summary); @@ -334,6 +339,8 @@ it("rejects an ambiguous method and a producer response for a different overload items: [], }; const tools: JadxToolPort = { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: (name) => Promise.resolve(name === "get_class_summary" ? summary : xrefs), text: () => Promise.reject(new Error("unexpected text call")), @@ -381,6 +388,8 @@ const invokeSite = { const traceWithReference = (fields: Record) => analyzeJadxRequest( { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: (name) => Promise.resolve( name === "get_class_summary" @@ -482,6 +491,8 @@ it.each([ await expect( analyzeJadxRequest( { + listClasses: () => + Promise.reject(new Error("unexpected class inventory call")), json: () => Promise.resolve(invalidSummary), text: () => Promise.reject(new Error("unexpected text call")), }, diff --git a/tests/boundary/android/jadxProtocol.test.ts b/tests/boundary/android/jadxProtocol.test.ts index 70457bfc8c..c860dfc490 100644 --- a/tests/boundary/android/jadxProtocol.test.ts +++ b/tests/boundary/android/jadxProtocol.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "vitest"; import { parseJadxEnvelope, + parseJadxClassPage, parseJadxJson, normalizeJadxText, parseJadxFailure, @@ -36,7 +37,10 @@ it("validates provider text envelopes and rejects unsupported content rather tha { content: [{ type: "text", text: "reported error" }], isError: true }, "inspect_android_package", ), - ).toEqual({ text: "reported error", failed: true }); + ).toEqual({ + content: [{ type: "text", text: "reported error" }], + isError: true, + }); for (const response of [ { content: [] }, { content: [{ type: "image", data: "AA==", mimeType: "image/png" }] }, @@ -109,3 +113,44 @@ it("rejects invalid APK selectors and accepts an explicit zero overload without }).success, ).toBe(true); }); + +it("preserves JSON page and envelope annotations while rejecting unsupported inventory scope", () => { + const page = { + scope: "top_level", + total_classes: 1, + offset: 0, + next_offset: 1, + limit: 4096, + items: ["fixture.Target"], + audit: { source: "雪", missing: null }, + }; + Object.defineProperty(page, "__proto__", { + value: { observed: "page" }, + enumerable: true, + }); + expect(parseJadxClassPage(page)).toEqual(page); + expect(Object.hasOwn(parseJadxClassPage(page), "__proto__")).toBe(true); + expect(() => parseJadxClassPage({ ...page, scope: "all_dex" })).toThrow(); + const response = { + content: [ + { + type: "text", + text: JSON.stringify(page), + annotations: { audience: ["assistant"] }, + }, + ], + _meta: { page: 1 }, + }; + Object.defineProperty(response, "__proto__", { + value: { observed: "envelope" }, + enumerable: true, + }); + Object.defineProperty(response.content[0], "__proto__", { + value: { observed: "content" }, + enumerable: true, + }); + const parsed = parseJadxEnvelope(response, "search_android_classes"); + expect(parsed).toEqual(response); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(Object.hasOwn(parsed.content[0], "__proto__")).toBe(true); +}); diff --git a/tests/boundary/process/androidSearchEvidenceBudget.test.ts b/tests/boundary/process/androidSearchEvidenceBudget.test.ts index 7f5be8c0ac..eac53ea19f 100644 --- a/tests/boundary/process/androidSearchEvidenceBudget.test.ts +++ b/tests/boundary/process/androidSearchEvidenceBudget.test.ts @@ -1,7 +1,10 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it as test, onTestFinished } from "vitest"; +import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js"; -import { parseEvidence } from "../../../src/domain/evidence.js"; +import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js"; +import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js"; +import { err } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { @@ -11,29 +14,54 @@ import { const INVENTORY_CLASS_COUNT = 7096; -/** Count inventoried names across raw call records: MCP pages ride in text content. */ -const rawInventoryNames = (raw: unknown): number => { - const calls = - ( - raw as { - calls?: { - operation: string; - response: { content?: { text?: string }[] }; - }[]; - } - ).calls ?? []; - return calls - .flatMap((call) => call.response.content ?? []) - .map((entry) => - entry.text === undefined ? {} : (JSON.parse(entry.text) as unknown), - ) - .filter((page) => Array.isArray((page as { items?: unknown[] }).items)) - .flatMap((page) => (page as { items: unknown[] }).items).length; +interface RawJadxCall { + readonly operation: string; + readonly input?: { readonly offset?: number; readonly query?: string }; + readonly response: { + readonly _meta?: Readonly>; + readonly content?: readonly { + readonly text?: string; + readonly annotations?: unknown; + readonly _meta?: Readonly>; + }[]; + }; +} + +const callsOf = (raw: unknown): readonly RawJadxCall[] => + (raw as { calls?: readonly RawJadxCall[] }).calls ?? []; + +const listClassCalls = (raw: unknown): readonly RawJadxCall[] => + callsOf(raw).filter(({ operation }) => operation === "list_classes"); + +const responsePage = (call: RawJadxCall): Record => { + const text = call.response.content?.[0]?.text; + if (text === undefined) throw new Error("JADX response omitted page text"); + const page: unknown = JSON.parse(text); + if (page === null || typeof page !== "object" || Array.isArray(page)) + throw new Error("JADX page text was not an object"); + return page as Record; }; +const callMetadata = (call: RawJadxCall) => ({ + offset: call.input?.offset, + envelope: call.response._meta, + content: call.response.content?.[0]?._meta, + annotations: call.response.content?.[0]?.annotations, + fixture: responsePage(call).fixture_metadata, +}); + +/** Count names in complete source pages using the producer's raw JSON. */ +const rawInventoryNames = (raw: unknown): number => + listClassCalls(raw).reduce((sum, call) => { + const items = responsePage(call).items; + if (!Array.isArray(items)) + throw new Error("Complete page omitted class names"); + return sum + items.length; + }, 0); + test("delivers bounded search_android_classes Evidence while retaining the complete inventory", async () => { const { provider, apk, launches } = await createJadxProtocolFixture( - "normal", + "search-audit-metadata", undefined, { classCount: INVENTORY_CLASS_COUNT - 1 }, ); @@ -111,6 +139,44 @@ test("delivers bounded search_android_classes Evidence while retaining the compl expect(rawInventoryNames(inventorySource?.raw_result)).toBe( INVENTORY_CLASS_COUNT, ); + const summaryPages = listClassCalls(inventory.raw_result); + const completePages = listClassCalls(inventorySource?.raw_result); + expect(summaryPages).toHaveLength(2); + expect(completePages).toHaveLength(2); + expect( + summaryPages.map((call) => { + const page = responsePage(call); + return { + offset: page.offset, + next_offset: page.next_offset, + total_classes: page.total_classes, + limit: page.limit, + items_omitted: page.items_omitted, + }; + }), + ).toEqual([ + { + offset: 0, + next_offset: 4096, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items_omitted: 4096, + }, + { + offset: 4096, + next_offset: INVENTORY_CLASS_COUNT, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items_omitted: INVENTORY_CLASS_COUNT - 4096, + }, + ]); + expect(summaryPages.map(callMetadata)).toEqual( + completePages.map(callMetadata), + ); + expect(completePages.map(responsePage).map((page) => page.items)).toEqual([ + expect.arrayContaining(["fixture.Target", "fixture.Class0"]), + expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]), + ]); const empty = await search("fixture.Missing"); expect( @@ -122,28 +188,125 @@ test("delivers bounded search_android_classes Evidence while retaining the compl coverage: "complete", }); expect(JSON.stringify(empty.raw_result)).not.toContain("fixture.Class"); + expect(listClassCalls(empty.raw_result).map(responsePage)).toMatchObject([ + { + offset: 0, + next_offset: INVENTORY_CLASS_COUNT, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items_omitted: 0, + fixture_metadata: { + page_offset: 0, + retained: "unknown page field", + }, + }, + ]); await client.close(); await server.close(); await verifyJadxFixtureCleanup(launches); }); -test("keeps complete matching producer rows inline without a retaining session", async () => { +test("keeps the complete paged inventory inline without a retaining session", async () => { const { service, apk, launches, provider } = await createJadxProtocolFixture( - "normal", + "search-audit-metadata", undefined, { classCount: INVENTORY_CLASS_COUNT - 1 }, ); const result = await service.execute("search_android_classes", { path: apk, - query: "Target", + query: "", }); if (!result.ok) throw result.error; - // Complete CLI Evidence keeps every matching row, without unrelated names. - expect(rawInventoryNames(result.value.raw_result)).toBe(1); + // No retaining writer means the CLI receives the unmodified source pages. + expect(rawInventoryNames(result.value.raw_result)).toBe( + INVENTORY_CLASS_COUNT, + ); + const fullPages = listClassCalls(result.value.raw_result); + expect(fullPages).toHaveLength(2); + expect(fullPages.map(responsePage)).toMatchObject([ + { + offset: 0, + next_offset: 4096, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items: expect.arrayContaining(["fixture.Target", "fixture.Class0"]), + }, + { + offset: 4096, + next_offset: INVENTORY_CLASS_COUNT, + total_classes: INVENTORY_CLASS_COUNT, + limit: 4096, + items: expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]), + }, + ]); + expect(responsePage(fullPages[0]!)).toMatchObject({ + fixture_metadata: { + page_offset: 0, + retained: "unknown page field", + }, + }); + for (const [index, call] of fullPages.entries()) { + expect(call.response._meta).toEqual({ + fixture_envelope_offset: index * 4096, + }); + expect(call.response.content?.[0]?._meta).toEqual({ + fixture_content_offset: index * 4096, + }); + expect(call.response.content?.[0]?.annotations).toEqual({ + audience: ["user"], + priority: 0.5, + }); + } expect(JSON.stringify(result.value.raw_result)).not.toContain( - "fixture.Class", + "inventory_elided", + ); + expect(JSON.stringify(result.value.raw_result)).not.toContain( + "items_omitted", ); await provider.close(); await verifyJadxFixtureCleanup(launches); }); + +test("withholds inline summary when retaining complete Evidence fails", async () => { + const { provider, apk, launches } = await createJadxProtocolFixture( + "search-audit-metadata", + undefined, + { classCount: INVENTORY_CLASS_COUNT - 1 }, + ); + const writeFailure = new EvidenceIntegrityError( + "The complete Android search Evidence could not be retained", + ); + const retained: Evidence[] = []; + const service = new AndroidAnalysisService(provider, (evidence) => { + retained.push(evidence); + return err(writeFailure); + }); + + const result = await service.execute("search_android_classes", { + path: apk, + query: "", + }); + + expect(result).toEqual({ ok: false, error: writeFailure }); + expect(retained).toHaveLength(1); + expect(retained[0]?.operation).toBe("search_android_classes"); + expect( + androidResultSchemas.search_android_classes.parse( + retained[0]?.normalized_result, + ), + ).toMatchObject({ + total_classes: INVENTORY_CLASS_COUNT, + matches: expect.arrayContaining(["fixture.Target", "fixture.Class7094"]), + coverage: "complete", + }); + expect(rawInventoryNames(retained[0]?.raw_result)).toBe( + INVENTORY_CLASS_COUNT, + ); + expect(JSON.stringify(retained[0]?.raw_result)).not.toContain( + "items_omitted", + ); + + await provider.close(); + await verifyJadxFixtureCleanup(launches); +}); diff --git a/tests/boundary/providers/android/androidLateCompletion.test.ts b/tests/boundary/providers/android/androidLateCompletion.test.ts index a4f22bb99b..0293586dad 100644 --- a/tests/boundary/providers/android/androidLateCompletion.test.ts +++ b/tests/boundary/providers/android/androidLateCompletion.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "vitest"; import { AndroidAnalysisService } from "../../../../src/application/android/AndroidAnalysisService.js"; import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; +import { ok } from "../../../../src/domain/result.js"; import { parseEvidence } from "../../../../src/domain/evidence.js"; import type { AnalysisExecution } from "../../../../src/application/AnalysisProvider.js"; import { createJadxProtocolFixture } from "../../../fixtures/android/jadx.js"; @@ -32,18 +33,25 @@ it.each([ if (!control.ok) throw control.error; const controller = new AbortController(); const observations: AnalysisExecution[] = []; - const service = new AndroidAnalysisService({ - inspectAvailability: fixture.provider.inspectAvailability.bind( - fixture.provider, - ), - close: fixture.provider.close.bind(fixture.provider), - execute: async (...args) => { - const completed = await fixture.provider.execute(...args); - if (completed.ok) observations.push(completed.value); - controller.abort(); - return completed; + let records = 0; + const service = new AndroidAnalysisService( + { + inspectAvailability: fixture.provider.inspectAvailability.bind( + fixture.provider, + ), + close: fixture.provider.close.bind(fixture.provider), + execute: async (...args) => { + const completed = await fixture.provider.execute(...args); + if (completed.ok) observations.push(completed.value); + controller.abort(); + return completed; + }, }, - }); + () => { + records += 1; + return ok("added" as const); + }, + ); const result = await service.execute(operation, input, { signal: controller.signal, }); @@ -66,6 +74,7 @@ it.each([ normalized_result: control.value.normalized_result, }); expect(observations).toHaveLength(1); + expect(records).toBe(0); expect(partial.raw_result).toEqual(observations[0]?.rawResult); expect(control.value.confidence).toBe( operation === "inspect_android_method" ? "derived" : "observed", diff --git a/tests/fixtures/android/jadx-mcp.mjs b/tests/fixtures/android/jadx-mcp.mjs index a2ac588406..470b57b1c8 100644 --- a/tests/fixtures/android/jadx-mcp.mjs +++ b/tests/fixtures/android/jadx-mcp.mjs @@ -127,14 +127,33 @@ register("list_classes", ({ offset, limit, query = "" }) => { const name = classes[nextOffset++]; if (name.includes(query)) matching.push(name); } - return json({ + const page = { scope: "top_level", total_classes: classes.length, next_offset: nextOffset, offset, limit, items: mode === "empty-page" ? [] : matching, - }); + ...(mode === "search-audit-metadata" + ? { + fixture_metadata: { + page_offset: offset, + retained: "unknown page field", + }, + } + : {}), + }; + const response = json(page); + if (mode !== "search-audit-metadata") return response; + return { + ...response, + _meta: { fixture_envelope_offset: offset }, + content: response.content.map((entry) => ({ + ...entry, + annotations: { audience: ["user"], priority: 0.5 }, + _meta: { fixture_content_offset: offset }, + })), + }; }); register("get_class_summary", () => json({ From bc411c24befceced5a9ea1ca1c1ce123fd02bf48 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:22:52 +0800 Subject: [PATCH 2/2] test: align boundary fixtures with producer contracts Model GDB console completion, use real directory reads and cleanup for late-enumeration failures, and distinguish supported PTX pointer qualifiers from unknown declarations. Preserve original assertion and cleanup failures through scoped test finalizers. --- .../referenceSourceQueuedDirectories.test.ts | 65 +++++---- tests/boundary/gpu/codeObjects.test.ts | 20 ++- .../boundary/mcp/gdbCloseDisposition.test.ts | 137 +++++++++--------- 3 files changed, 119 insertions(+), 103 deletions(-) diff --git a/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts b/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts index 6bc4be7072..c5df498bc9 100644 --- a/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts +++ b/tests/boundary/filesystem/referenceSourceQueuedDirectories.test.ts @@ -1,4 +1,3 @@ -import type { Dir, Dirent } from "node:fs"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -7,30 +6,42 @@ import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; -const { opendirMock } = vi.hoisted(() => ({ opendirMock: vi.fn() })); - -vi.mock("node:fs/promises", async (importOriginal) => ({ - ...(await importOriginal()), - opendir: opendirMock, +const injectedReadFailure = vi.hoisted(() => ({ + path: undefined as string | undefined, + error: undefined as Error | undefined, })); -const directoryHandle = (items: readonly (string | Error | null)[]): Dir => { - let index = 0; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); return { - async read() { - const item = items[index++]; - if (item instanceof Error) throw item; - return item === null || item === undefined - ? null - : ({ name: item } as Dirent); + ...actual, + opendir: async (...args: Parameters) => { + const handle = await actual.opendir(...args); + const [path] = args; + const { path: failingPath, error } = injectedReadFailure; + if (path !== failingPath || error === undefined) return handle; + + const read = handle.read.bind(handle); + let failureInjected = false; + Object.defineProperty(handle, "read", { + configurable: true, + value: async () => { + const entry = await read(); + if (entry === null && !failureInjected) { + failureInjected = true; + throw error; + } + return entry; + }, + }); + return handle; }, - async close() {}, - } as unknown as Dir; -}; + }; +}); it.skipIf(process.platform === "win32")( "traverses directories lstat-observed before their parent enumeration failed", - async () => { + async ({ onTestFinished }) => { const root = await createTestTempDirectory( "rea-queued-source-directories-", ); @@ -46,18 +57,11 @@ it.skipIf(process.platform === "win32")( new Error("injected directory read failure"), { code: "EIO" }, ); - opendirMock.mockImplementation(async (path: string) => { - if (path === root) return directoryHandle(["container", null]); - if (path === container) - return directoryHandle([ - "visible.txt", - "hidden", - "alias-file.txt", - "alias-directory", - readFailure, - ]); - if (path === hidden) return directoryHandle(["target.txt", null]); - throw new Error(`Unexpected directory traversal: ${path}`); + injectedReadFailure.path = container; + injectedReadFailure.error = readFailure; + onTestFinished(() => { + injectedReadFailure.path = undefined; + injectedReadFailure.error = undefined; }); const imported = await importReferenceSource({ @@ -73,6 +77,7 @@ it.skipIf(process.platform === "win32")( path: "container", kind: "directory", tree_state: "unreadable", + limitations: [expect.stringContaining(readFailure.message)], }), expect.objectContaining({ path: "container/hidden", diff --git a/tests/boundary/gpu/codeObjects.test.ts b/tests/boundary/gpu/codeObjects.test.ts index 3b5c01a208..cbe4ced8bd 100644 --- a/tests/boundary/gpu/codeObjects.test.ts +++ b/tests/boundary/gpu/codeObjects.test.ts @@ -429,7 +429,7 @@ it.each([ ]); }); it.each([ - ["parameter", ".u64 output", ".u64 .ptr .global output"], + ["parameter", ".u64 output", ".u64 .vendor_specific output"], ["shared", "scratch[32]", "scratch[]"], ] as const)( "marks unknown PTX %s declarations partial while retaining known facts", @@ -459,6 +459,24 @@ it.each([ } }, ); +it("keeps supported PTX pointer qualifiers complete through the provider", async () => { + const bytes = Buffer.from( + ptxFixture.replace(".u64 output", ".u64 .ptr .global output"), + ); + const result = await inspect(bytes); + if (!result.ok) throw result.error; + expect(result.value.objects[0]!.inspection).toBe("complete"); + expect(result.value.coverage.payloads).toBe("complete"); + expect(result.value.ptx_artifacts[0]!.text).toBe(bytes.toString()); + expect(result.value.kernels[0]).toMatchObject({ + parameter_count: 2, + parameter_bytes: 12, + parameters: [ + { name: "output", offset: 0, size: 8 }, + { name: "value", offset: 8, size: 4 }, + ], + }); +}); it.each(["whitespace", "comment"] as const)( "recognizes standalone PTX after more than 64 KiB of leading %s", async (kind) => { diff --git a/tests/boundary/mcp/gdbCloseDisposition.test.ts b/tests/boundary/mcp/gdbCloseDisposition.test.ts index f49455760d..4cff147d26 100644 --- a/tests/boundary/mcp/gdbCloseDisposition.test.ts +++ b/tests/boundary/mcp/gdbCloseDisposition.test.ts @@ -1,12 +1,12 @@ import { randomUUID } from "node:crypto"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { chmod, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { McpServer } from "@modelcontextprotocol/server"; -import { expect, it } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js"; import { silentLogger } from "../../../src/logger.js"; @@ -16,7 +16,7 @@ import { createToolResultDelivery } from "../../../src/server/toolResult.js"; it.skipIf(process.platform === "win32").each(["kill", "detach"])( "retains a valid GDB session through disposition refusal and explicit %s recovery", async (disposition) => { - const directory = await mkdtemp(join(tmpdir(), "rea-gdb-disposition-")); + const directory = await createTestTempDirectory("rea-gdb-disposition-"); const provider = join(directory, "fake-gdb.mjs"); await writeFile( provider, @@ -33,6 +33,10 @@ createInterface({ input: process.stdin }).on('line', line => { process.stdout.write(token + '^running\\n*stopped,reason="breakpoint-hit",thread-id="1"\\n(gdb)\\n'); return; } + if (command === '-gdb-show mi-async') { + process.stdout.write(token + '^done,value="off"\\n(gdb)\\n'); + return; + } const disposition = command === '-interpreter-exec console "kill"' || command === '-interpreter-exec console "detach"'; if (disposition) live = false; const result = command === '-list-thread-groups --recurse 1' @@ -50,10 +54,12 @@ createInterface({ input: process.stdin }).on('line', line => { platform: "linux", environment: { REA_GDB_COMMAND: provider }, }); + onTestFinished(() => manager.closeAll()); const server = new McpServer({ name: "gdb-disposition-test", version: "1", }); + onTestFinished(() => server.close()); registerGdbTools( server, manager, @@ -61,80 +67,67 @@ createInterface({ input: process.stdin }).on('line', line => { createToolResultDelivery(undefined), ); const client = new Client({ name: "gdb-disposition-test", version: "1" }); + onTestFinished(() => client.close()); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - let sessionId: string | undefined; - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const started = await client.callTool({ - name: "start_gdb_session", - arguments: {}, - }); - sessionId = toolContract("start_gdb_session").outputSchema.parse( - started.structuredContent, - ).session_id; - const running = await client.callTool({ - name: "gdb_console", - arguments: { session_id: sessionId, command: "run" }, - }); - expect(running.isError).not.toBe(true); - for (let attempt = 0; attempt < 2; attempt += 1) { - const refusal = await client.callTool({ - name: "close_gdb_session", - arguments: { session_id: sessionId }, - }); - expect(refusal.isError).toBe(true); - expect(refusal.structuredContent).toMatchObject({ - error: { - code: "capability_unavailable", - category: "unavailable", - retryable: true, - remediation: { action: expect.stringContaining("gdb_console") }, - details: { - provider_id: "gnu.gdb", - operation: "close_gdb_session", - reason: expect.stringContaining("inferior"), - }, - }, - }); - expect(JSON.stringify(refusal.structuredContent)).not.toContain( - "invalid_value", - ); - expect(manager.status(sessionId)?.state).toBe("ready"); - } - const disposed = await client.callTool({ - name: "gdb_console", - arguments: { session_id: sessionId, command: disposition }, - }); - expect(disposed.isError).not.toBe(true); - const closed = await client.callTool({ + await server.connect(serverTransport); + await client.connect(clientTransport); + const started = await client.callTool({ + name: "start_gdb_session", + arguments: {}, + }); + const sessionId = toolContract("start_gdb_session").outputSchema.parse( + started.structuredContent, + ).session_id; + const running = await client.callTool({ + name: "gdb_console", + arguments: { session_id: sessionId, command: "run" }, + }); + expect(running.isError).not.toBe(true); + for (let attempt = 0; attempt < 2; attempt += 1) { + const refusal = await client.callTool({ name: "close_gdb_session", arguments: { session_id: sessionId }, }); - expect(closed.isError).not.toBe(true); - expect(closed.structuredContent).toMatchObject({ - target_state: "none_observed", + expect(refusal.isError).toBe(true); + expect(refusal.structuredContent).toMatchObject({ + error: { + code: "capability_unavailable", + category: "unavailable", + retryable: true, + remediation: { action: expect.stringContaining("gdb_console") }, + details: { + provider_id: "gnu.gdb", + operation: "close_gdb_session", + reason: expect.stringContaining("inferior"), + }, + }, }); - expect(manager.status(sessionId)).toBeUndefined(); - const unknown = await client.callTool({ - name: "close_gdb_session", - arguments: { session_id: randomUUID() }, - }); - expect(unknown.structuredContent).toMatchObject({ - error: { code: "invalid_request", category: "invalid_input" }, - }); - } finally { - if (sessionId !== undefined && manager.status(sessionId) !== undefined) { - const disposed = await manager.execute(sessionId, "kill"); - if (!disposed.ok) throw disposed.error; - const closed = await manager.close(sessionId); - if (!closed.ok) throw closed.error; - } - await client.close(); - await server.close(); - await manager.closeAll(); - await rm(directory, { recursive: true, force: true }); + expect(JSON.stringify(refusal.structuredContent)).not.toContain( + "invalid_value", + ); + expect(manager.status(sessionId)?.state).toBe("ready"); } + const disposed = await client.callTool({ + name: "gdb_console", + arguments: { session_id: sessionId, command: disposition }, + }); + expect(disposed.isError).not.toBe(true); + const closed = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: sessionId }, + }); + expect(closed.isError).not.toBe(true); + expect(closed.structuredContent).toMatchObject({ + target_state: "none_observed", + }); + expect(manager.status(sessionId)).toBeUndefined(); + const unknown = await client.callTool({ + name: "close_gdb_session", + arguments: { session_id: randomUUID() }, + }); + expect(unknown.structuredContent).toMatchObject({ + error: { code: "invalid_request", category: "invalid_input" }, + }); }, );