Skip to content
Closed
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
58 changes: 58 additions & 0 deletions nodejs/samples/todo-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* TodoTracker — using the Copilot SDK's query() convenience API.
*
* Run: COPILOT_CLI_URL=localhost:PORT npx tsx todo-tracker.ts
* (start the CLI first with: copilot --headless)
*/

import { z } from "zod";
import { query, defineTool } from "@github/copilot-sdk";

class TodoTracker {
private todos: any[] = [];
Comment on lines +11 to +12
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The sample uses any[] type for the todos array. Following TypeScript best practices and conventions visible in the codebase (which uses explicit types throughout), consider defining a proper type for todo items to provide better type safety.

For example: private todos: Array<{ content: string; status: 'completed' | 'in_progress' | 'pending'; activeForm?: string }> = [];

Suggested change
class TodoTracker {
private todos: any[] = [];
type TodoItem = {
content: string;
status: "completed" | "in_progress" | "pending";
activeForm?: string;
};
class TodoTracker {
private todos: TodoItem[] = [];

Copilot uses AI. Check for mistakes.

displayProgress() {
if (this.todos.length === 0) return;
const completed = this.todos.filter((t) => t.status === "completed").length;
const inProgress = this.todos.filter((t) => t.status === "in_progress").length;
const total = this.todos.length;
console.log(`\nProgress: ${completed}/${total} completed`);
console.log(`Currently working on: ${inProgress} task(s)\n`);
this.todos.forEach((todo, index) => {
const icon =
todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔧" : "❌";
const text = todo.status === "in_progress" ? todo.activeForm : todo.content;
console.log(`${index + 1}. ${icon} ${text}`);
});
}

todoWriteTool = defineTool("TodoWrite", {
description: "Write or update the todo list for the current task.",
parameters: z.object({
todos: z.array(
z.object({
content: z.string(),
status: z.enum(["completed", "in_progress", "pending"]),
activeForm: z.string().optional(),
}),
),
}),
handler: ({ todos }) => {
this.todos = todos;
this.displayProgress();
return "Todo list updated.";
},
});

async trackQuery(prompt: string) {
for await (const event of query({ prompt, tools: [this.todoWriteTool], maxTurns: 20 })) {
if (event.type === "assistant.message_delta") {
process.stdout.write(event.data.deltaContent);
}
}
}
}

// Usage
const tracker = new TodoTracker();
await tracker.trackQuery("Build a complete authentication system with todos");
2 changes: 2 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
export { CopilotClient } from "./client.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export { defineTool, approveAll } from "./types.js";
export { query } from "./query.js";
export type {
ConnectionState,
CopilotClientOptions,
Expand All @@ -30,6 +31,7 @@ export type {
PermissionHandler,
PermissionRequest,
PermissionRequestResult,
QueryOptions,
ResumeSessionConfig,
SessionConfig,
SessionEvent,
Expand Down
132 changes: 132 additions & 0 deletions nodejs/src/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

/**
* `query()` — a convenience wrapper that provides a simple async-iterator API
* over the Copilot SDK. It creates a client + session, sends a prompt, and
* yields every {@link SessionEvent} as it arrives.
*
* @example
* ```typescript
* import { query, defineTool } from "@github/copilot-sdk";
*
* for await (const event of query({ prompt: "Hello!", tools: [myTool] })) {
* if (event.type === "assistant.message_delta") {
* process.stdout.write(event.data.deltaContent);
* }
* }
* ```
*
* @module query
*/

import { CopilotClient } from "./client.js";
import { approveAll, type QueryOptions, type SessionEvent } from "./types.js";

/**
* Send a prompt and yield every session event as an async iterator.
*
* Internally creates a {@link CopilotClient} and session, sends the prompt,
* and tears everything down when the iterator finishes or is broken out of.
*
* The generator ends when:
* - The session becomes idle (model finished), or
* - `maxTurns` tool-calling turns have been reached, or
* - The consumer breaks out of the `for await` loop.
*/
export async function* query(options: QueryOptions): AsyncGenerator<SessionEvent> {
const cliUrl = options.cliUrl ?? process.env.COPILOT_CLI_URL;
const client = new CopilotClient({
...(cliUrl ? { cliUrl } : {}),
...(options.cliPath ? { cliPath: options.cliPath } : {}),
...(options.githubToken ? { githubToken: options.githubToken } : {}),
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

When cliUrl is provided (either via options or COPILOT_CLI_URL environment variable), the CopilotClient constructor will throw an error if githubToken is also provided, as per the validation on lines 200-204 of client.ts. However, the query function on line 39 reads COPILOT_CLI_URL from the environment, and on line 43 conditionally passes githubToken if provided.

This creates a problematic scenario: if a user sets COPILOT_CLI_URL environment variable and also provides githubToken in QueryOptions, the client constructor will throw an error. The error message is clear ("githubToken and useLoggedInUser cannot be used with cliUrl"), but it would be better to either:

  1. Document this limitation clearly in QueryOptions
  2. Validate and provide a clearer error message in the query function itself
  3. Handle this case by not passing githubToken when cliUrl is present

This issue also appears on line 40 of the same file.

Suggested change
...(options.githubToken ? { githubToken: options.githubToken } : {}),
// When using cliUrl (including via COPILOT_CLI_URL), CopilotClient does not allow githubToken.
...(!cliUrl && options.githubToken ? { githubToken: options.githubToken } : {}),

Copilot uses AI. Check for mistakes.
});

try {
const session = await client.createSession({
model: options.model,
tools: options.tools ?? [],
streaming: options.streaming ?? true,
systemMessage: options.systemMessage,
onPermissionRequest: options.onPermissionRequest ?? approveAll,
});

// Bridge the event-driven API to an async iterator via a simple queue.
let resolve: ((value: IteratorResult<SessionEvent>) => void) | null = null;
const buffer: SessionEvent[] = [];
let done = false;
let turns = 0;

const finish = () => {
done = true;
if (resolve) {
resolve({ value: undefined as unknown as SessionEvent, done: true });
resolve = null;
}
};

session.on((event: SessionEvent) => {
if (done) return;

// Count tool-calling turns for maxTurns support.
if (
options.maxTurns &&
event.type === "assistant.message" &&
event.data.toolRequests?.length
) {
turns++;
if (turns >= options.maxTurns) {
if (resolve) {
resolve({ value: event, done: false });
resolve = null;
} else {
buffer.push(event);
}
finish();
return;
}
}
Comment on lines +73 to +89
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The logic for handling maxTurns has a potential issue: it yields the final assistant.message event before calling finish(), but it doesn't handle subsequent events that might arrive before the session is fully terminated. This could lead to buffered events being lost or the iterator continuing to process events after the maxTurns limit is reached.

Additionally, when maxTurns is reached on line 79, the iterator finishes immediately after yielding that event. However, other events (like tool.execution_start, tool.execution_end) that are already in flight or buffered might not be yielded, potentially leaving the caller without complete information about the final turn.

This issue also appears in the following locations of the same file:

  • line 55
  • line 118

Copilot uses AI. Check for mistakes.

if (event.type === "session.idle") {
if (resolve) {
resolve({ value: event, done: false });
resolve = null;
} else {
buffer.push(event);
}
finish();
return;
}

if (resolve) {
resolve({ value: event, done: false });
resolve = null;
} else {
buffer.push(event);
}
});
Comment on lines +69 to +108
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The event handler does not handle session.error events, which means errors that occur during session execution will not terminate the async iterator or propagate to the caller. According to the SessionEvent types and the sendAndWait pattern in session.ts (lines 175-178), session.error events should be caught and handled appropriately.

Consider adding a handler for session.error that calls finish() to terminate the iterator and potentially throws or yields the error event so the caller can handle it appropriately.

This issue also appears on line 69 of the same file.

Copilot uses AI. Check for mistakes.

await session.send({ prompt: options.prompt });

while (!done || buffer.length > 0) {
if (buffer.length > 0) {
yield buffer.shift()!;
} else if (done) {
break;
} else {
yield await new Promise<SessionEvent>((r) => {
resolve = (result) => {
if (result.done) {
r(undefined as unknown as SessionEvent);
} else {
r(result.value);
}
};
});
}
}
} finally {
await client.stop();
}
Comment on lines +129 to +131
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The query() function creates a new session but never explicitly destroys it. While client.stop() is called in the finally block (line 130), which stops the client and closes all sessions, it would be more explicit and follow better resource management practices to call session.destroy() before client.stop(). This is the pattern used in the codebase examples (see nodejs/src/client.ts lines 105-106 and nodejs/samples/chat.ts).

Copilot uses AI. Check for mistakes.
}
Comment on lines +38 to +132
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The new query() convenience API lacks test coverage. Given that the codebase has comprehensive E2E tests for other SDK features (see nodejs/test/e2e/ with tests for client lifecycle, session handling, tools, etc.), this new feature should have corresponding tests to verify:

  • Basic query functionality with streaming events
  • maxTurns limiting behavior
  • Environment variable handling (COPILOT_CLI_URL)
  • Cleanup on early termination (breaking out of the iterator)
  • Error propagation from session errors

Copilot uses AI. Check for mistakes.
52 changes: 52 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,3 +1052,55 @@ export interface ForegroundSessionInfo {
/** Workspace path of the foreground session */
workspacePath?: string;
}

// ============================================================================
// Query Options (convenience API)
// ============================================================================

/**
* Options for the `query()` convenience function.
* Combines the essential CopilotClient and SessionConfig options
* into a single flat configuration.
*/
export interface QueryOptions {
/** The user prompt to send. */
prompt: string;

/** Tools exposed to the model. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Tool<any>[];

/** Model to use (e.g. "gpt-5", "claude-sonnet-4.5"). */
model?: string;

/**
* Maximum number of agentic turns (assistant responses that include tool calls).
* The generator will end after this many tool-calling turns.
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The documentation for maxTurns describes it as "Maximum number of agentic turns (assistant responses that include tool calls)", but the implementation only counts assistant.message events that have toolRequests. This means that if the assistant sends a message without tool calls, it won't count toward the limit. However, according to the description, turns should be counted as "assistant responses that include tool calls", which is technically correct but could be clearer.

Consider clarifying the documentation to explicitly state that only assistant messages with tool calls count toward the limit, and that final messages without tool calls will still be yielded even if they occur after maxTurns is reached.

Suggested change
* The generator will end after this many tool-calling turns.
* Only assistant messages that include tool calls count toward this limit.
* After this many tool-calling turns, no further tool calls will be made,
* but a final assistant message without tool calls may still be returned.

Copilot uses AI. Check for mistakes.
* If not set, the agent runs until it is idle.
*/
maxTurns?: number;

/** Enable streaming delta events. @default true */
streaming?: boolean;

/**
* URL of an existing Copilot CLI server (e.g. "localhost:8080").
* When provided, the client will not spawn a CLI process.
*/
cliUrl?: string;

/** Path to the CLI executable. */
cliPath?: string;

/** GitHub token for authentication. */
githubToken?: string;

/**
* Handler for permission requests.
* @default approveAll
*/
onPermissionRequest?: PermissionHandler;

/** System message configuration. */
systemMessage?: SystemMessageConfig;
}
Loading