diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 8ae19755a..b529408cb 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,26 @@ 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. + const isValidTimeout = + timeout === undefined || + (typeof timeout === "number" && Number.isFinite(timeout) && timeout >= 0); + if (!isValidTimeout) { + 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})` + ); + } + const effectiveTimeout = timeout ?? 60_000; let resolveIdle: () => void; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 42c0ff18e..8e15234d5 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -24,6 +24,54 @@ 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. + 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); + const error = await captureRejection( + session.sendAndWait("hello", { ms: 30000 } as any) + ); + 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("reports a null timeout as null rather than object", async () => { + const session = new CopilotSession("session-1", {} as any); + 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( + "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( + "sendAndWait timeout must be a non-negative number of milliseconds (got -1)" + ); + }); + }); + it("forwards canvas declarations and request flags in session.create", async () => { const client = new CopilotClient(); await client.start();