Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/coding-agent/src/extensibility/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve writable semantics for the model accessor

When an extension assigns a handler-local replacement to ctx.model, which the non-readonly ExtensionContext.model declaration permits and the previous spread produced as a writable data property, copying this getter descriptor leaves it getter-only. In strict-mode extension modules, ctx.model = replacement now throws a TypeError, causing the runner to report the handler as failed and skip its remaining work; preserve the live getter while still allowing a local assignment override.

Useful? React with 👍 / 👎.

descriptors.signal = {
configurable: true,
enumerable: true,
writable: true,
value: signal,
Comment on lines +84 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the injected signal writable

When an extension assigns a replacement to ctx.signal, which the non-readonly ExtensionContext type permits and the previous object-spread implementation supported, this descriptor defaults writable to false. Because extension modules run in strict mode, that assignment now throws a TypeError, causing the runner to treat the handler as failed and discard the rest of its work; set writable: true to preserve the prior property semantics.

Useful? React with 👍 / 👎.

};
return Object.defineProperties({}, descriptors) as ExtensionContext;
}

/**
* Events handled by the generic emit() method.
Expand Down Expand Up @@ -690,7 +700,7 @@ export class ExtensionRunner {
): Promise<TResult | undefined> {
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<typeof EXTENSION_HANDLER_TIMEOUT>(resolve => {
timeout = setTimeout(() => resolve(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
Expand Down
73 changes: 73 additions & 0 deletions packages/coding-agent/test/extensions-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading