diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 42b1677a7c..e85f542aa2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - Telegram daemon restart now revokes every persisted callback alias before polling. Reconnecting sessions must replay a pending ask to receive fresh, owner-bound aliases; old controls remain stale, and their keyboards are best-effort terminalized when the original Telegram message id is available. Shutdown now fences new session messages and drains every admitted handler before final callback persistence and ownership release, preventing a successful send racing shutdown from publishing alias state after a successor takes ownership (#3727). +- Extension handler timeout signals now preserve lazy, live context accessors instead of eagerly snapshotting them. Model changes made through SDK controls are immediately visible to later context reads, and unused getters can no longer reject lifecycle emission before the runner's extension error boundary (#3817). - Direct interactive launches inside tmux now bind automatic window renames to the originating pane's immutable pane/window identities and observed window index. If that binding changes before mutation, GJC preserves every window name instead of renaming whichever window became active (#3808). ### Fixed diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 23ac33a39a..f09337fa48 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -79,6 +79,16 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void { const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout"); const MAX_PENDING_CREDENTIAL_DISABLED = 32; +function createHandlerContext(ctx: ExtensionContext, signal: AbortSignal): ExtensionContext { + const descriptors = Object.getOwnPropertyDescriptors(ctx); + descriptors.signal = { + configurable: true, + enumerable: true, + writable: true, + value: signal, + }; + return Object.defineProperties({}, descriptors) as ExtensionContext; +} /** * Events handled by the generic emit() method. @@ -690,7 +700,7 @@ export class ExtensionRunner { ): Promise { let timeout: NodeJS.Timeout | undefined; const abortController = new AbortController(); - const handlerContext: ExtensionContext = { ...ctx, signal: abortController.signal }; + const handlerContext = createHandlerContext(ctx, abortController.signal); try { const timeoutPromise = new Promise(resolve => { timeout = setTimeout(() => resolve(EXTENSION_HANDLER_TIMEOUT), timeoutMs); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 9241a9f8be..fa088f9399 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -12,6 +12,8 @@ import { ExtensionRunner, testSetExtensionHandlerTimeoutMs, } from "@gajae-code/coding-agent/extensibility/extensions/runner"; +import type { ExtensionContext } from "@gajae-code/coding-agent/extensibility/extensions/types"; + import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; import { getProjectAgentDir, logger, TempDir } from "@gajae-code/utils"; @@ -645,6 +647,77 @@ describe("ExtensionRunner", () => { }); describe("handler timeouts", () => { + it("preserves live context accessors and writable signal semantics", async () => { + let currentModel = { id: "first-model" }; + const observedModels: string[] = []; + const extension = { + path: "live-context-extension", + handlers: new Map([ + [ + "session_start", + [ + async (_event: unknown, ctx: ExtensionContext) => { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + const descriptor = Object.getOwnPropertyDescriptor(ctx, "signal"); + expect(descriptor).toMatchObject({ + configurable: true, + enumerable: true, + value: ctx.signal, + writable: true, + }); + const replacementSignal = new AbortController().signal; + expect(() => { + ctx.signal = replacementSignal; + }).not.toThrow(); + expect(ctx.signal).toBe(replacementSignal); + observedModels.push(ctx.model?.id ?? "missing"); + currentModel = { id: "second-model" }; + observedModels.push(ctx.model?.id ?? "missing"); + }, + ], + ], + ]), + }; + const runner = new ExtensionRunner( + [extension as never], + { flagValues: new Map(), pendingProviderRegistrations: [] } as never, + tempDir.path(), + sessionManager, + modelRegistry, + ); + runner.initialize({} as never, { getModel: () => currentModel } as never); + + await expect(runner.emit({ type: "session_start" })).resolves.toBeUndefined(); + expect(observedModels).toEqual(["first-model", "second-model"]); + }); + + it("does not evaluate unused context accessors before the handler error boundary", async () => { + const handler = vi.fn(async (_event: unknown, ctx: ExtensionContext) => { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + }); + const extension = { + path: "lazy-context-extension", + handlers: new Map([["session_start", [handler]]]), + }; + const runner = new ExtensionRunner( + [extension as never], + { flagValues: new Map(), pendingProviderRegistrations: [] } as never, + tempDir.path(), + sessionManager, + modelRegistry, + ); + runner.initialize( + {} as never, + { + getModel: () => { + throw new Error("model accessor must stay lazy"); + }, + } as never, + ); + + await expect(runner.emit({ type: "session_start" })).resolves.toBeUndefined(); + expect(handler).toHaveBeenCalledTimes(1); + }); it("times out session_start handlers, emits an error, and continues to sibling extensions", async () => { const hangExtensionPath = path.join(tempDir.path(), "hang-session-start.ts"); const fastExtensionPath = path.join(tempDir.path(), "fast-session-start.ts");