From 67384b23e56e95da780d73f8e019f9c1585652f8 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:03:51 +0800 Subject: [PATCH] fix(jeb): cache parsed metadata independently of optional version Own the parsed engine facts separately from identity projections. Reuse successful unknown-version metadata, retain explicit refresh, and bind each execution identity to its own selected metadata under concurrent inspection. Refs #4600 and #4597. Six affected files pass 58 tests; static, documentation and discovery checks passed. Real JEB and reconnect behavior remain unverified; prepared reconnect PR3754 stays separate pending cleanup ownership work. --- docs/jeb-analysis.md | 1 + src/jeb/JebProvider.test.ts | 73 ++++++++++++++++++++ src/jeb/JebProvider.ts | 48 ++++++++------ tests/boundary/mcp/jebMcp.test.ts | 106 +++++++++++++++++++----------- 4 files changed, 168 insertions(+), 60 deletions(-) diff --git a/docs/jeb-analysis.md b/docs/jeb-analysis.md index 986701e779..7cf1941642 100644 --- a/docs/jeb-analysis.md +++ b/docs/jeb-analysis.md @@ -52,6 +52,7 @@ All four are read-only with respect to the target file; only `open_jeb_project` - The engine caps `list_units` pages at 100; a full page is reported as `coverage: "partial"` because more units may exist. An empty remainder is `complete` only when fewer units than requested were returned. - When `decompile_jeb_item` omits `unit_path`, the engine implicitly selects the project's first code unit; REA reports `unit_path: null` and a limitation instead of guessing the effective unit. - Engine refusals (`success: false` envelopes) are preserved as unsupported-target errors carrying the engine's own message, for example a nonexistent item address. +- Successfully parsed client information is reused for the lifetime of the MCP connection, including when the engine omits its version. Explicit client inspection and availability probes refresh it. - An unreachable endpoint is a provider-availability failure with recovery guidance; REA does not retry or spawn the engine. Real-engine verification lives in the JEB lane described in [testing.md](testing.md); mock transport tests do not establish real-engine compatibility. diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index e8b46d7a66..b874e228f2 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -409,3 +409,76 @@ it("forwards in-flight cancellation to the MCP call", async () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); }); + +it("refreshes cached unknown metadata only through an explicit information probe", async () => { + const responses: Record = { + get_client_information: { success: true, gui_client: false }, + list_units: { success: true, units: [] }, + }; + const connection = scriptedConnection(responses); + const provider = providerWith(connection); + try { + const first = await provider.execute({ + operation: "list_jeb_units", + input: { count: 2, index: 0 }, + }); + if (!first.ok) throw first.error; + expect(first.value.provider.version).toBeNull(); + responses.get_client_information = { + success: true, + version: "5.49.0", + gui_client: true, + }; + const inspected = await provider.execute({ + operation: "inspect_jeb_client", + input: {}, + }); + if (!inspected.ok) throw inspected.error; + const after = await provider.execute({ + operation: "list_jeb_units", + input: { count: 2, index: 0 }, + }); + if (!after.ok) throw after.error; + expect(after.value.provider.version).toBe("5.49.0"); + expect(after.value.result).toMatchObject({ + engine: { version: "5.49.0", gui_client: true }, + }); + expect(connection.calls.map(([name]) => name)).toEqual([ + "list_units", + "get_client_information", + "get_client_information", + "list_units", + ]); + } finally { + await provider.close(); + } +}); + +it("keeps concurrent client inspections bound to their own parsed metadata", async () => { + const connection = scriptedConnection({}); + let replies = 0; + connection.call = async () => ({ + success: true, + version: `inspection-${++replies}`, + gui_client: replies === 1, + }); + const provider = providerWith(connection); + try { + const results = await Promise.all([ + provider.execute({ operation: "inspect_jeb_client", input: {} }), + provider.execute({ operation: "inspect_jeb_client", input: {} }), + ]); + for (const [index, result] of results.entries()) { + if (!result.ok) throw result.error; + expect(result.value.provider.version).toBe(`inspection-${index + 1}`); + expect(result.value.result).toMatchObject({ + engine: { + version: result.value.provider.version, + gui_client: index === 0, + }, + }); + } + } finally { + await provider.close(); + } +}); diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 9fc43fde28..652351a13b 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -105,8 +105,16 @@ const refusalReason = (payload: unknown, fallback: string): string => { export class JebProvider implements JebAnalysisPort { readonly #connection: JebMcpConnection; readonly #endpoint: URL; - #identity: ProviderIdentity = JEB_PROVIDER_IDENTITY; - #guiClient: boolean | null = null; + #clientInformation: + | Pick, "version" | "gui_client"> + | undefined; + + #identity(version: string | null | undefined): ProviderIdentity { + return { + ...JEB_PROVIDER_IDENTITY, + version: version ?? null, + }; + } constructor(options: JebProviderOptions = {}) { const environment = options.environment ?? {}; @@ -128,7 +136,10 @@ export class JebProvider implements JebAnalysisPort { ): Promise { try { const tools = new Set(await this.#connection.listTools(signal)); - await this.#refreshClientInformation("inspect_jeb_client", signal); + const { information } = await this.#refreshClientInformation( + "inspect_jeb_client", + signal, + ); return { status: "available", code: null, @@ -138,7 +149,7 @@ export class JebProvider implements JebAnalysisPort { ).filter((operation) => tools.has(JEB_ENGINE_TOOLS[operation])), diagnostics: { endpoint: this.#endpoint.toString(), - engine_version: this.#identity.version, + engine_version: information.version ?? null, }, }; } catch (cause) { @@ -169,11 +180,6 @@ export class JebProvider implements JebAnalysisPort { const execution = await this.#dispatch(request, options?.signal); if (options?.signal?.aborted) return err(new AnalysisCancelledError(request.operation)); - if (execution.ok) - this.#identity = { - ...this.#identity, - version: execution.value.provider.version, - }; return execution; } catch (cause) { if (options?.signal?.aborted) @@ -218,13 +224,14 @@ export class JebProvider implements JebAnalysisPort { readonly endpoint: string; readonly gui_client: boolean | null; }> { - if (this.#identity.version === null) - await this.#refreshClientInformation(operation, signal); + const information = + this.#clientInformation ?? + (await this.#refreshClientInformation(operation, signal)).information; return { name: "jeb", - version: this.#identity.version, + version: information.version ?? null, endpoint: this.#endpoint.toString(), - gui_client: this.#guiClient, + gui_client: information.gui_client ?? null, }; } @@ -242,11 +249,10 @@ export class JebProvider implements JebAnalysisPort { const information = clientInformation.parse( this.#requireSuccess(payload, operation), ); - this.#identity = { - ...this.#identity, - version: information.version ?? null, + this.#clientInformation = { + version: information.version, + gui_client: information.gui_client, }; - this.#guiClient = information.gui_client ?? null; return { information, payload }; } @@ -266,7 +272,7 @@ export class JebProvider implements JebAnalysisPort { message: information.message ?? null, }); return ok( - createAnalysisExecution(result, this.#identity, { + createAnalysisExecution(result, this.#identity(information.version), { rawResult: payload, limitations: information.version === undefined @@ -299,7 +305,7 @@ export class JebProvider implements JebAnalysisPort { units: opened.units, }); return ok( - createAnalysisExecution(result, this.#identity, { + createAnalysisExecution(result, this.#identity(engine.version), { rawResult: payload, limitations: [ "JEB resolves and analyzes the artifact on the engine host; REA did not open the target itself.", @@ -338,7 +344,7 @@ export class JebProvider implements JebAnalysisPort { coverage: units.length < count ? "complete" : "partial", }); return ok( - createAnalysisExecution(result, this.#identity, { + createAnalysisExecution(result, this.#identity(engine.version), { rawResult: payload, limitations: [ "The engine caps unit pages at 100; a full page means more units may exist.", @@ -373,7 +379,7 @@ export class JebProvider implements JebAnalysisPort { text: decompiled.text, }); return ok( - createAnalysisExecution(result, this.#identity, { + createAnalysisExecution(result, this.#identity(engine.version), { rawResult: payload, limitations: unit_path === undefined diff --git a/tests/boundary/mcp/jebMcp.test.ts b/tests/boundary/mcp/jebMcp.test.ts index 4bd85759c6..c0b776f5d9 100644 --- a/tests/boundary/mcp/jebMcp.test.ts +++ b/tests/boundary/mcp/jebMcp.test.ts @@ -296,30 +296,40 @@ it("keeps the MCP server usable when REA_JEB_MCP_URL is invalid", async () => { }); }); -it.each([ - { operation: "list_jeb_units", tool: "list_units", input: { count: 2 } }, - { - operation: "decompile_jeb_item", - tool: "decompile_code_item", - input: { - item_address: "LSample;->value()I", - item_kind: "method", - unit_path: "sample.dex", +it.each( + [ + { operation: "list_jeb_units", tool: "list_units", input: { count: 2 } }, + { + operation: "decompile_jeb_item", + tool: "decompile_code_item", + input: { + item_address: "LSample;->value()I", + item_kind: "method", + unit_path: "sample.dex", + }, }, - }, - { - operation: "open_jeb_project", - tool: "open_project", - input: { path: "sample.apk" }, - }, -])( - "attributes auxiliary metadata refusal to requested $operation through MCP", - async ({ operation, tool, input }) => { - const fixture = await startJebMcpFixture((name) => - name === "get_client_information" + { + operation: "open_jeb_project", + tool: "open_project", + input: { path: "sample.apk" }, + }, + ].flatMap((request) => + ["refusal", "unknown-version"].map((metadata) => ({ + ...request, + metadata, + })), + ), +)( + "preserves requested $operation through MCP with metadata $metadata", + async ({ operation, tool, input, metadata }) => { + let informationCalls = 0; + const fixture = await startJebMcpFixture((name) => { + if (name !== "get_client_information") return jebPrimaryResponses[name]!; + informationCalls++; + return metadata === "refusal" || informationCalls > 1 ? { success: false, message: "Client information temporarily refused" } - : jebPrimaryResponses[name]!, - ); + : { success: true, gui_client: false }; + }); const provider = new JebProvider({ environment: { REA_JEB_MCP_URL: fixture.endpoint }, }); @@ -336,24 +346,42 @@ it.each([ InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); - const failed = await client.callTool({ - name: operation, - arguments: input, - }); - expect(failed.isError).toBe(true); - const text = failed.content - .map((block) => (block.type === "text" ? block.text : "")) - .join(""); - expect(JSON.parse(text)).toMatchObject({ - error: { - code: "unsupported_target", - details: { - operation, - reason: "Client information temporarily refused", + const call = () => client.callTool({ name: operation, arguments: input }); + if (metadata === "unknown-version") { + for (let attempt = 0; attempt < 2; attempt++) { + const called = await call(); + expect(called.isError).not.toBe(true); + const text = called.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + const evidence = parseEvidence(JSON.parse(text)); + expect(evidence.provider.version).toBeNull(); + expect(evidence.normalized_result).toMatchObject({ + engine: { version: null, gui_client: false }, + }); + expect(evidence.raw_result).toEqual(jebPrimaryResponses[tool]); + } + expect(fixture.calls).toEqual([tool, "get_client_information", tool]); + } else { + const failed = await client.callTool({ + name: operation, + arguments: input, + }); + expect(failed.isError).toBe(true); + const text = failed.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + expect(JSON.parse(text)).toMatchObject({ + error: { + code: "unsupported_target", + details: { + operation, + reason: "Client information temporarily refused", + }, }, - }, - }); - expect(fixture.calls).toEqual([tool, "get_client_information"]); + }); + expect(fixture.calls).toEqual([tool, "get_client_information"]); + } } finally { await client.close(); await server.close();