Skip to content

Commit d691281

Browse files
React to send becoming nonblocking
1 parent b8a836b commit d691281

7 files changed

Lines changed: 158 additions & 17 deletions

nodejs/README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ Represents a single conversation session.
120120

121121
##### `send(options: MessageOptions): Promise<string>`
122122

123-
Send a message to the session.
123+
Send a message to the session. Returns immediately after the message is queued; use event handlers or `sendAndWait()` to wait for completion.
124124

125125
**Options:**
126126

@@ -130,6 +130,19 @@ Send a message to the session.
130130

131131
Returns the message ID.
132132

133+
##### `sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>`
134+
135+
Send a message and wait until the session becomes idle.
136+
137+
**Options:**
138+
139+
- `prompt: string` - The message/prompt to send
140+
- `attachments?: Array<{type, path, displayName}>` - File attachments
141+
- `mode?: "enqueue" | "immediate"` - Delivery mode
142+
- `timeout?: number` - Optional timeout in milliseconds
143+
144+
Returns the final assistant message event, or undefined if none was received.
145+
133146
##### `on(handler: SessionEventHandler): () => void`
134147

135148
Subscribe to session events. Returns an unsubscribe function.
@@ -299,8 +312,8 @@ const session1 = await client.createSession({ model: "gpt-5" });
299312
const session2 = await client.createSession({ model: "claude-sonnet-4.5" });
300313

301314
// Both sessions are independent
302-
await session1.send({ prompt: "Hello from session 1" });
303-
await session2.send({ prompt: "Hello from session 2" });
315+
await session1.sendAndWait({ prompt: "Hello from session 1" });
316+
await session2.sendAndWait({ prompt: "Hello from session 2" });
304317
```
305318

306319
### Custom Session IDs

nodejs/examples/basic-example.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,22 +96,17 @@ async function main() {
9696

9797
// Send a simple message
9898
console.log("💬 Sending message...");
99-
const messageId = await session.send({
99+
await session.sendAndWait({
100100
prompt: "You can call the lookup_fact tool. First, please tell me 2+2.",
101101
});
102-
console.log(`✅ Message sent: ${messageId}\n`);
103-
104-
// Wait a bit for events to arrive
105-
await new Promise((resolve) => setTimeout(resolve, 5000));
102+
console.log("✅ Message completed\n");
106103

107104
// Send another message
108105
console.log("\n💬 Sending follow-up message...");
109-
await session.send({
106+
await session.sendAndWait({
110107
prompt: "Great. Now use lookup_fact to tell me something about Node.js.",
111108
});
112-
113-
// Wait for response
114-
await new Promise((resolve) => setTimeout(resolve, 5000));
109+
console.log("✅ Follow-up completed\n");
115110

116111
// Clean up
117112
console.log("\n🧹 Cleaning up...");

nodejs/src/session.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,16 @@ import type {
3131
* const session = await client.createSession({ model: "gpt-4" });
3232
*
3333
* // Subscribe to events
34-
* const unsubscribe = session.on((event) => {
34+
* session.on((event) => {
3535
* if (event.type === "assistant.message") {
3636
* console.log(event.data.content);
3737
* }
3838
* });
3939
*
40-
* // Send a message
41-
* await session.send({ prompt: "Hello, world!" });
40+
* // Send a message and wait for completion
41+
* await session.sendAndWait({ prompt: "Hello, world!" });
4242
*
4343
* // Clean up
44-
* unsubscribe();
4544
* await session.destroy();
4645
* ```
4746
*/
@@ -91,6 +90,77 @@ export class CopilotSession {
9190
return (response as { messageId: string }).messageId;
9291
}
9392

93+
/**
94+
* Sends a message to this session and waits until the session becomes idle.
95+
*
96+
* This is a convenience method that combines {@link send} with waiting for
97+
* the `session.idle` event. Use this when you want to block until the
98+
* assistant has finished processing the message.
99+
*
100+
* Events are still delivered to handlers registered via {@link on} while waiting.
101+
*
102+
* @param options - The message options including the prompt and optional attachments
103+
* @param timeout - Optional timeout in milliseconds. If not provided, waits indefinitely.
104+
* @returns A promise that resolves with the final assistant message when the session becomes idle,
105+
* or undefined if no assistant message was received
106+
* @throws Error if the timeout is reached before the session becomes idle
107+
* @throws Error if the session has been destroyed or the connection fails
108+
*
109+
* @example
110+
* ```typescript
111+
* // Send and wait for completion with a 5-minute timeout
112+
* const response = await session.sendAndWait(
113+
* { prompt: "What is 2+2?" },
114+
* 300_000
115+
* );
116+
* console.log(response?.data.content); // "4"
117+
* ```
118+
*/
119+
async sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined> {
120+
// Track whether we've started the send - only count idle events after this point
121+
let sendStarted = false;
122+
let resolveIdle: () => void;
123+
const idlePromise = new Promise<void>((resolve) => {
124+
resolveIdle = resolve;
125+
});
126+
127+
// Track the last assistant message received
128+
let lastAssistantMessage: SessionEvent | undefined;
129+
130+
// Register listener BEFORE sending, but only resolve for idle events
131+
// that arrive after we've initiated the send (to ignore stale events)
132+
const unsubscribe = this.on((event) => {
133+
if (sendStarted) {
134+
if (event.type === "assistant.message") {
135+
lastAssistantMessage = event;
136+
} else if (event.type === "session.idle") {
137+
resolveIdle();
138+
}
139+
}
140+
});
141+
142+
try {
143+
// Mark send as started and initiate - these are synchronous so no events
144+
// can sneak in between setting the flag and starting the send
145+
sendStarted = true;
146+
await this.send(options);
147+
148+
// Wait for idle with optional timeout
149+
if (timeout !== undefined) {
150+
const timeoutPromise = new Promise<never>((_, reject) => {
151+
setTimeout(() => reject(new Error(`Timeout after ${timeout}ms waiting for session.idle`)), timeout);
152+
});
153+
await Promise.race([idlePromise, timeoutPromise]);
154+
} else {
155+
await idlePromise;
156+
}
157+
158+
return lastAssistantMessage;
159+
} finally {
160+
unsubscribe();
161+
}
162+
}
163+
94164
/**
95165
* Subscribes to events from this session.
96166
*

nodejs/test/e2e/harness/sdkTestContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
1717
const __dirname = dirname(__filename);
1818
const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots");
1919

20-
export const CLI_PATH = resolve(__dirname, "../../../node_modules/@github/copilot/index.js");
20+
export const CLI_PATH = process.env.COPILOT_CLI_PATH || resolve(__dirname, "../../../node_modules/@github/copilot/index.js");
2121

2222
export async function createSdkTestContext() {
2323
const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-")));

nodejs/test/e2e/session.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,46 @@ function getSystemMessage(exchange: ParsedHttpExchange): string | undefined {
342342
| undefined;
343343
return systemMessage?.content;
344344
}
345+
346+
describe("Send Blocking Behavior", async () => {
347+
// Tests for Issue #17: send() should return immediately, not block until turn completes
348+
const { copilotClient: client } = await createSdkTestContext();
349+
350+
it("send returns immediately while events stream in background", async () => {
351+
const session = await client.createSession();
352+
353+
const events: string[] = [];
354+
session.on((event) => {
355+
events.push(event.type);
356+
});
357+
358+
await session.send({ prompt: "What is 1+1?" });
359+
360+
// send() should return before turn completes (no session.idle yet)
361+
expect(events).not.toContain("session.idle");
362+
363+
// Wait for turn to complete
364+
const message = await getFinalAssistantMessage(session);
365+
366+
expect(message.data.content).toContain("2");
367+
expect(events).toContain("session.idle");
368+
expect(events).toContain("assistant.message");
369+
});
370+
371+
it("sendAndWait blocks until session.idle and returns final assistant message", async () => {
372+
const session = await client.createSession();
373+
374+
const events: string[] = [];
375+
session.on((event) => {
376+
events.push(event.type);
377+
});
378+
379+
const response = await session.sendAndWait({ prompt: "What is 2+2?" });
380+
381+
expect(response).toBeDefined();
382+
expect(response?.type).toBe("assistant.message");
383+
expect(response?.data.content).toContain("4");
384+
expect(events).toContain("session.idle");
385+
expect(events).toContain("assistant.message");
386+
});
387+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
models:
2+
- claude-sonnet-4.5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: What is 1+1?
9+
- role: assistant
10+
content: 1 + 1 = 2
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
models:
2+
- claude-sonnet-4.5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: What is 2+2?
9+
- role: assistant
10+
content: 2 + 2 = 4

0 commit comments

Comments
 (0)