From da7fb2071f023298204cbc3d9c28bdb7042d1d2d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 6 Aug 2026 08:09:10 -0700 Subject: [PATCH 1/3] Fix: stop the dev API server disposing the AgentFile it borrows from AgentLoader AgentLoader.getAgentFile() lends out the AgentFile it keeps in its own registry. The agent-graph handler and executeAgentRun() bound that handle with `await using`, so the first request for an app deleted the compiled artifact and latched the shared handle as disposed. Both sites now borrow with `const`, and getAgentFile() documents the ownership contract. --- dev/src/server/adk_api_server.ts | 6 ++---- dev/src/utils/agent_loader.ts | 7 +++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dev/src/server/adk_api_server.ts b/dev/src/server/adk_api_server.ts index a52ad63ab..08545bda6 100644 --- a/dev/src/server/adk_api_server.ts +++ b/dev/src/server/adk_api_server.ts @@ -336,7 +336,7 @@ export class AdkApiServer { const functionCalls = getFunctionCalls(event); const functionResponses = getFunctionResponses(event); - await using agentFile = await this.agentLoader.getAgentFile(appName); + const agentFile = await this.agentLoader.getAgentFile(appName); const loaded = await agentFile.load(); const rootAgent = isApp(loaded) ? loaded.rootAgent : loaded; @@ -1036,9 +1036,7 @@ export class AdkApiServer { runConfig?: RunConfig; abortSignal: AbortSignal; }): AsyncGenerator { - await using agentFile = await this.agentLoader.getAgentFile( - options.appName, - ); + const agentFile = await this.agentLoader.getAgentFile(options.appName); const loaded = await agentFile.load(); const runner = await this.getRunner(loaded, options.appName); diff --git a/dev/src/utils/agent_loader.ts b/dev/src/utils/agent_loader.ts index d5097c482..21d87cfe2 100644 --- a/dev/src/utils/agent_loader.ts +++ b/dev/src/utils/agent_loader.ts @@ -458,6 +458,13 @@ export class AgentLoader { return appNames.sort(); } + /** + * Lends the caller the `AgentFile` this loader owns for `agentName`. + * + * Every caller shares the handle, and only `invalidateAll()` or + * `disposeAll()` ends its life. A caller must not dispose it: disposal + * deletes the compiled artifact that the other callers still read. + */ async getAgentFile(agentName: string): Promise { await this.preloadAgents(); From d6969b300ae4384edaebffe12e952fba1c4065b4 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 6 Aug 2026 08:09:19 -0700 Subject: [PATCH 2/3] Test: pin that the dev API server leaves the borrowed AgentFile usable The server test harness handed every getAgentFile() call a fresh object, which hid the shared-handle defect. It now lends one stub per test that mirrors the real disposal latch, and three tests assert the handle survives repeated run and agent-graph requests. A loader test pins the premise: getAgentFile() returns the same instance, and disposing it poisons what the loader serves. --- dev/test/server/adk_api_server_test.ts | 123 +++++++++++++++++++++++-- dev/test/utils/agent_loader_test.ts | 18 ++++ 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index d6db712d2..9dfb78608 100644 --- a/dev/test/server/adk_api_server_test.ts +++ b/dev/test/server/adk_api_server_test.ts @@ -6,6 +6,7 @@ import {AGENT_CARD_PATH, AgentCard} from '@a2a-js/sdk'; import { + BaseAgent, BaseArtifactService, BaseMemoryService, BaseSessionService, @@ -213,8 +214,41 @@ const TEST_AGENT = new TestAgent({ ], }); +const COMPILED_AGENT_PATH = '/tmp/adk_agent_loader-test/testApp.mjs'; + +/** + * Test double for the `AgentFile` the loader lends out. It mirrors the real + * disposal semantics: once disposed, `getFilePath()` throws for good. + */ +interface AgentFileStub { + disposeCount: number; + load: () => Promise; + getFilePath: () => string; + [Symbol.asyncDispose]: () => Promise; +} + +function createAgentFileStub(): AgentFileStub { + const stub: AgentFileStub = { + disposeCount: 0, + load: () => Promise.resolve(TEST_AGENT), + getFilePath: () => { + if (stub.disposeCount > 0) { + throw new Error('Agent is disposed and can not be used'); + } + return COMPILED_AGENT_PATH; + }, + [Symbol.asyncDispose]: () => { + stub.disposeCount++; + return Promise.resolve(); + }, + }; + + return stub; +} + describe('AdkWebServer', () => { let agentLoader: AgentLoader; + let agentFile: AgentFileStub; let sessionService: BaseSessionService; let memoryService: BaseMemoryService; let artifactService: BaseArtifactService; @@ -222,17 +256,10 @@ describe('AdkWebServer', () => { let client: HttpClient; beforeEach(async () => { + agentFile = createAgentFileStub(); agentLoader = { listAgents: () => Promise.resolve(['testApp']), - getAgentFile: () => - Promise.resolve({ - load() { - return Promise.resolve(TEST_AGENT); - }, - async [Symbol.asyncDispose](): Promise { - return; - }, - }), + getAgentFile: () => Promise.resolve(agentFile), } as unknown as AgentLoader; sessionService = new InMemorySessionService(); memoryService = new InMemoryMemoryService(); @@ -943,6 +970,84 @@ describe('AdkWebServer', () => { }); }); + describe('agent file lifecycle', () => { + const RUN_BODY = { + appName: 'testApp', + userId: 'testUser', + sessionId: 'sessionId', + newMessage: {parts: [{text: 'Hello test agent!'}], role: 'user'}, + }; + const GRAPH_PATH = + '/apps/testApp/users/testUser/sessions/fullSession/events/event1/graph'; + + async function postRun(): Promise { + const response = await client.post('/run', RUN_BODY); + + expect(response.status).toBe(200); + } + + async function getGraph(times: number): Promise { + const originalGetSession = sessionService.getSession; + sessionService.getSession = async () => + createSession({ + id: 'fullSession', + appName: 'testApp', + userId: 'testUser', + events: [ + createEvent({ + id: 'event1', + author: 'model', + content: {parts: [{functionCall: {name: 'foo', args: {}}}]}, + invocationId: 'inv-1', + }), + ], + }); + + try { + for (let i = 0; i < times; i++) { + const response = await client.get<{dotSrc: string}>(GRAPH_PATH); + + expect(response.status).toBe(200); + } + } finally { + sessionService.getSession = originalGetSession; + } + } + + function expectAgentFileNotDisposed(): void { + expect(agentFile.disposeCount).toBe(0); + expect(() => agentFile.getFilePath()).not.toThrow(); + } + + beforeEach(async () => { + await sessionService.createSession({ + appName: 'testApp', + userId: 'testUser', + sessionId: 'sessionId', + }); + }); + + it('leaves the loader-owned agent file usable after two sequential run requests', async () => { + await postRun(); + await postRun(); + + expectAgentFileNotDisposed(); + }); + + it('leaves the loader-owned agent file usable after two sequential agent-graph requests', async () => { + await getGraph(2); + + expectAgentFileNotDisposed(); + }); + + it('does not dispose the loader-owned agent file when a run and a graph request share an app', async () => { + await postRun(); + await getGraph(1); + + expectAgentFileNotDisposed(); + }); + }); + describe('A2A', () => { const A2A_TOKEN = 'test-a2a-token'; let a2aServer: AdkApiServer | undefined; diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index 2285df895..b65835afb 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -717,6 +717,24 @@ describe('AgentLoader', () => { await agentLoader.disposeAll(); }); + it('returns the same shared AgentFile instance for repeated getAgentFile calls', async () => { + const loader = new AgentLoader(tempAgentsDir); + const first = await loader.getAgentFile('agent2'); + const second = await loader.getAgentFile('agent2'); + + expect(second).toBe(first); + + await first.load(); + await first.dispose(); + const afterDispose = await loader.getAgentFile('agent2'); + + expect(() => afterDispose.getFilePath()).toThrow( + 'Agent is disposed and can not be used', + ); + + await loader.disposeAll(); + }); + it('disposes all agent files', async () => { const agentLoader = new AgentLoader(tempAgentsDir); await agentLoader.listAgents(); From bb70e32c1753793337249e42044fb4d760e147e9 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 6 Aug 2026 08:51:32 -0700 Subject: [PATCH 3/3] Test: drop the getFilePath assertion the stub cannot fail The stub threw from getFilePath() exactly when disposeCount was above zero, so the second assertion restated the first. The server never calls getFilePath() on the borrowed handle either. The real AgentFile keeps that behaviour pinned in dev/test/utils/agent_loader_test.ts. --- dev/test/server/adk_api_server_test.ts | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index 9dfb78608..f2631f2fb 100644 --- a/dev/test/server/adk_api_server_test.ts +++ b/dev/test/server/adk_api_server_test.ts @@ -214,16 +214,10 @@ const TEST_AGENT = new TestAgent({ ], }); -const COMPILED_AGENT_PATH = '/tmp/adk_agent_loader-test/testApp.mjs'; - -/** - * Test double for the `AgentFile` the loader lends out. It mirrors the real - * disposal semantics: once disposed, `getFilePath()` throws for good. - */ +/** Test double for the `AgentFile` the loader lends out. */ interface AgentFileStub { disposeCount: number; load: () => Promise; - getFilePath: () => string; [Symbol.asyncDispose]: () => Promise; } @@ -231,12 +225,6 @@ function createAgentFileStub(): AgentFileStub { const stub: AgentFileStub = { disposeCount: 0, load: () => Promise.resolve(TEST_AGENT), - getFilePath: () => { - if (stub.disposeCount > 0) { - throw new Error('Agent is disposed and can not be used'); - } - return COMPILED_AGENT_PATH; - }, [Symbol.asyncDispose]: () => { stub.disposeCount++; return Promise.resolve(); @@ -1014,11 +1002,6 @@ describe('AdkWebServer', () => { } } - function expectAgentFileNotDisposed(): void { - expect(agentFile.disposeCount).toBe(0); - expect(() => agentFile.getFilePath()).not.toThrow(); - } - beforeEach(async () => { await sessionService.createSession({ appName: 'testApp', @@ -1031,20 +1014,20 @@ describe('AdkWebServer', () => { await postRun(); await postRun(); - expectAgentFileNotDisposed(); + expect(agentFile.disposeCount).toBe(0); }); it('leaves the loader-owned agent file usable after two sequential agent-graph requests', async () => { await getGraph(2); - expectAgentFileNotDisposed(); + expect(agentFile.disposeCount).toBe(0); }); it('does not dispose the loader-owned agent file when a run and a graph request share an app', async () => { await postRun(); await getGraph(1); - expectAgentFileNotDisposed(); + expect(agentFile.disposeCount).toBe(0); }); });