Skip to content
Open
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
21 changes: 21 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -275,6 +276,26 @@ export class CopilotSession {
): Promise<AssistantMessageEvent | undefined> {
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;
Expand Down
48 changes: 48 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>): Promise<Error> {
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();
Expand Down