From 4586433305f134849c008755a271030dc6f79466 Mon Sep 17 00:00:00 2001 From: Alberto Gimeno Date: Wed, 17 Jun 2026 11:47:39 +0200 Subject: [PATCH 1/3] Validate sendAndWait timeout to fix malformed error message (#915) When sendAndWait() received a non-numeric timeout (e.g. an object or NaN), the value was passed straight to setTimeout(), which coerced it to a 0ms delay. The session then rejected almost immediately with a misleading "Timeout after [object Object]ms waiting for session.idle" message rather than surfacing the real problem. Validate the timeout argument up front and throw a clear TypeError when it is not a non-negative finite number of milliseconds, so callers get an actionable error instead of a spurious immediate timeout. Note: this addresses the malformed-message bug from #915. The separate report that resumeSession() with customAgents never reaches idle is a CLI runtime behavior and is not addressable in the SDK. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/session.ts | 16 ++++++++++++++++ nodejs/test/client.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8ae19755a..d81cb80f2 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -254,6 +254,7 @@ export class CopilotSession { * @param timeout - Timeout in milliseconds (default: 60000). Controls how long to wait; does not abort in-flight agent work. * @returns A promise that resolves with the final assistant message when the session becomes idle, * or undefined if no assistant message was received + * @throws TypeError if `timeout` is provided but is not a non-negative finite number of milliseconds * @throws Error if the timeout is reached before the session becomes idle * @throws Error if the session has been disconnected or the connection fails * @@ -275,6 +276,21 @@ export class CopilotSession { ): Promise { const options: MessageOptions = typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } : optionsOrPrompt; + + // Guard against a non-numeric timeout reaching setTimeout(). Without this, + // an object or NaN is coerced to a 0ms delay, producing a spurious + // immediate timeout with a malformed "Timeout after [object Object]ms" + // message instead of a clear, actionable error. + if ( + timeout !== undefined && + (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0) + ) { + const received = typeof timeout === "number" ? `${timeout}` : typeof timeout; + throw new TypeError( + `sendAndWait timeout must be a non-negative number of milliseconds (got ${received})` + ); + } + const effectiveTimeout = timeout ?? 60_000; let resolveIdle: () => void; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 42c0ff18e..e937fc9d7 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -24,6 +24,38 @@ describe("CopilotClient", () => { expect(spy).not.toHaveBeenCalled(); }); + describe("sendAndWait timeout validation", () => { + // Regression for #915: a non-numeric timeout used to reach setTimeout(), + // which coerced it to a 0ms delay and rejected with a malformed + // "Timeout after [object Object]ms" message instead of a clear error. + it("rejects with a TypeError mentioning the received timeout type", async () => { + const session = new CopilotSession("session-1", {} as any); + await expect(session.sendAndWait("hello", { ms: 30000 } as any)).rejects.toThrow( + TypeError + ); + await expect(session.sendAndWait("hello", { ms: 30000 } as any)).rejects.toThrowError( + /sendAndWait timeout must be a non-negative number of milliseconds/ + ); + }); + + it("does not produce an [object Object] message for an object timeout", async () => { + const session = new CopilotSession("session-1", {} as any); + await expect(session.sendAndWait("hello", { ms: 30000 } as any)).rejects.toThrowError( + /timeout must be a non-negative number of milliseconds \(got object\)/ + ); + }); + + it("rejects when timeout is NaN", async () => { + const session = new CopilotSession("session-1", {} as any); + await expect(session.sendAndWait("hello", NaN)).rejects.toThrowError(/got NaN/); + }); + + it("rejects when timeout is negative", async () => { + const session = new CopilotSession("session-1", {} as any); + await expect(session.sendAndWait("hello", -1)).rejects.toThrowError(/got -1/); + }); + }); + it("forwards canvas declarations and request flags in session.create", async () => { const client = new CopilotClient(); await client.start(); From 30dc7e9bac1ae580cd61bb5e3db2a48f4aaca3c5 Mon Sep 17 00:00:00 2001 From: Alberto Gimeno Date: Wed, 17 Jun 2026 11:55:09 +0200 Subject: [PATCH 2/3] Address review feedback on timeout validation - Report a null timeout as "null" instead of "object" in the error message - Capture the rejection once per test instead of invoking sendAndWait twice - Assert the full error message (including the sendAndWait prefix) so the regression tests don't match unrelated errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/session.ts | 7 ++++++- nodejs/test/client.test.ts | 36 ++++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index d81cb80f2..e8e618e67 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -285,7 +285,12 @@ export class CopilotSession { timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0) ) { - const received = typeof timeout === "number" ? `${timeout}` : typeof timeout; + const received = + timeout === null + ? "null" + : typeof timeout === "number" + ? `${timeout}` + : typeof timeout; throw new TypeError( `sendAndWait timeout must be a non-negative number of milliseconds (got ${received})` ); diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index e937fc9d7..8e15234d5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -28,31 +28,47 @@ describe("CopilotClient", () => { // Regression for #915: a non-numeric timeout used to reach setTimeout(), // which coerced it to a 0ms delay and rejected with a malformed // "Timeout after [object Object]ms" message instead of a clear error. - it("rejects with a TypeError mentioning the received timeout type", async () => { + async function captureRejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error("expected sendAndWait to reject"); + }, + (error) => error + ); + } + + it("rejects an object timeout with a clear TypeError", async () => { const session = new CopilotSession("session-1", {} as any); - await expect(session.sendAndWait("hello", { ms: 30000 } as any)).rejects.toThrow( - TypeError + const error = await captureRejection( + session.sendAndWait("hello", { ms: 30000 } as any) ); - await expect(session.sendAndWait("hello", { ms: 30000 } as any)).rejects.toThrowError( - /sendAndWait timeout must be a non-negative number of milliseconds/ + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toBe( + "sendAndWait timeout must be a non-negative number of milliseconds (got object)" ); + expect(error.message).not.toContain("[object Object]"); }); - it("does not produce an [object Object] message for an object timeout", async () => { + it("reports a null timeout as null rather than object", async () => { const session = new CopilotSession("session-1", {} as any); - await expect(session.sendAndWait("hello", { ms: 30000 } as any)).rejects.toThrowError( - /timeout must be a non-negative number of milliseconds \(got object\)/ + const error = await captureRejection(session.sendAndWait("hello", null as any)); + expect(error.message).toBe( + "sendAndWait timeout must be a non-negative number of milliseconds (got null)" ); }); it("rejects when timeout is NaN", async () => { const session = new CopilotSession("session-1", {} as any); - await expect(session.sendAndWait("hello", NaN)).rejects.toThrowError(/got NaN/); + await expect(session.sendAndWait("hello", NaN)).rejects.toThrowError( + "sendAndWait timeout must be a non-negative number of milliseconds (got NaN)" + ); }); it("rejects when timeout is negative", async () => { const session = new CopilotSession("session-1", {} as any); - await expect(session.sendAndWait("hello", -1)).rejects.toThrowError(/got -1/); + await expect(session.sendAndWait("hello", -1)).rejects.toThrowError( + "sendAndWait timeout must be a non-negative number of milliseconds (got -1)" + ); }); }); From 293b590fe37f7b8d9a68b9f5c3ac06931232c89f Mon Sep 17 00:00:00 2001 From: Alberto Gimeno Date: Wed, 17 Jun 2026 12:03:12 +0200 Subject: [PATCH 3/3] Extract timeout check into a named isValidTimeout local for clarity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/session.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index e8e618e67..b529408cb 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -281,10 +281,10 @@ export class CopilotSession { // an object or NaN is coerced to a 0ms delay, producing a spurious // immediate timeout with a malformed "Timeout after [object Object]ms" // message instead of a clear, actionable error. - if ( - timeout !== undefined && - (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0) - ) { + const isValidTimeout = + timeout === undefined || + (typeof timeout === "number" && Number.isFinite(timeout) && timeout >= 0); + if (!isValidTimeout) { const received = timeout === null ? "null"