From 59640bad267da103666d69bc7aad4e0639f41300 Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Wed, 22 Jul 2026 04:42:20 +0900 Subject: [PATCH] improve(agents): skip whole-history deep clone on every turn when no context hook is registered (#99542) * perf(agents): skip per-turn context deep clone when no context handler is registered * perf(agents): avoid duplicate context handler scan --------- Co-authored-by: Peter Steinberger --- src/agents/sessions/extensions/runner.test.ts | 88 +++++++++++++++++++ src/agents/sessions/extensions/runner.ts | 6 ++ 2 files changed, 94 insertions(+) create mode 100644 src/agents/sessions/extensions/runner.test.ts diff --git a/src/agents/sessions/extensions/runner.test.ts b/src/agents/sessions/extensions/runner.test.ts new file mode 100644 index 000000000000..2bc291762709 --- /dev/null +++ b/src/agents/sessions/extensions/runner.test.ts @@ -0,0 +1,88 @@ +// Focused tests for emitContext clone gating: the per-turn deep clone of the +// session history must be skipped when no extension registered a "context" +// handler, while handler runs keep receiving an isolated clone. +import { describe, expect, it } from "vitest"; +import type { AgentMessage } from "../../runtime/index.js"; +import type { ModelRegistry } from "../model-registry.js"; +import type { SessionManager } from "../session-manager.js"; +import { ExtensionRunner } from "./runner.js"; +import type { Extension, ExtensionRuntime } from "./types.js"; + +type TestHandler = (...args: unknown[]) => Promise; + +function buildExtension(handlers?: Record): Extension { + return { + path: "/tmp/test-extension.ts", + resolvedPath: "/tmp/test-extension.ts", + sourceInfo: { + path: "/tmp/test-extension.ts", + source: "test", + scope: "temporary", + origin: "top-level", + }, + handlers: new Map(Object.entries(handlers ?? {})), + tools: new Map(), + messageRenderers: new Map(), + commands: new Map(), + flags: new Map(), + shortcuts: new Map(), + } as Extension; +} + +function buildRunner(extensions: Extension[]): ExtensionRunner { + return new ExtensionRunner( + extensions, + {} as ExtensionRuntime, + "/tmp", + {} as SessionManager, + {} as ModelRegistry, + ); +} + +function buildMessages(): AgentMessage[] { + return [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + ] as AgentMessage[]; +} + +describe("ExtensionRunner.emitContext", () => { + it("returns the original array without cloning when no context handlers are registered", async () => { + const messages = buildMessages(); + + const noExtensions = buildRunner([]); + expect(await noExtensions.emitContext(messages)).toBe(messages); + + const otherHandlersOnly = buildRunner([buildExtension({ user_bash: [async () => undefined] })]); + expect(await otherHandlersOnly.emitContext(messages)).toBe(messages); + }); + + it("keeps handler mutations isolated from the caller's messages", async () => { + const messages = buildMessages(); + const handler: TestHandler = async (event) => { + const contextEvent = event as { messages: AgentMessage[] }; + contextEvent.messages.push({ + role: "user", + content: [{ type: "text", text: "injected" }], + } as AgentMessage); + return undefined; + }; + const runner = buildRunner([buildExtension({ context: [handler] })]); + + const result = await runner.emitContext(messages); + + expect(result).not.toBe(messages); + expect(result).toHaveLength(3); + expect(messages).toHaveLength(2); + }); + + it("applies replacement messages returned by a context handler", async () => { + const replacement = [ + { role: "user", content: [{ type: "text", text: "replaced" }] }, + ] as AgentMessage[]; + const handler: TestHandler = async () => ({ messages: replacement }); + const runner = buildRunner([buildExtension({ context: [handler] })]); + + expect(await runner.emitContext(buildMessages())).toBe(replacement); + }); +}); diff --git a/src/agents/sessions/extensions/runner.ts b/src/agents/sessions/extensions/runner.ts index d2ce5d95dfbd..624c980c5e5d 100644 --- a/src/agents/sessions/extensions/runner.ts +++ b/src/agents/sessions/extensions/runner.ts @@ -910,6 +910,12 @@ export class ExtensionRunner { } async emitContext(messages: AgentMessage[]): Promise { + // Cloning the full session history is expensive (it can carry image + // payloads) and runs every turn, so skip it unless a context handler + // is actually registered. Handlers still receive an isolated clone. + if (!this.hasHandlers("context")) { + return messages; + } const ctx = this.createContext(); let currentMessages = structuredClone(messages);