Skip to content
Merged
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
11 changes: 11 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ namespace GitHub.Copilot.SDK;
/// </example>
public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
{
internal const string NoResultPermissionV2ErrorMessage =
"Permission handlers cannot return 'no-result' when connected to a protocol v2 server.";

/// <summary>
/// Minimum protocol version this SDK can communicate with.
/// </summary>
Expand Down Expand Up @@ -1394,8 +1397,16 @@ public async Task<PermissionRequestResponseV2> OnPermissionRequestV2(string sess
try
{
var result = await session.HandlePermissionRequestAsync(permissionRequest);
if (result.Kind == new PermissionRequestResultKind("no-result"))
{
throw new InvalidOperationException(NoResultPermissionV2ErrorMessage);
}
return new PermissionRequestResponseV2(result);
}
catch (InvalidOperationException ex) when (ex.Message == NoResultPermissionV2ErrorMessage)
{
throw;
}
catch (Exception)
{
return new PermissionRequestResponseV2(new PermissionRequestResult
Expand Down
4 changes: 4 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,10 @@ private async Task ExecutePermissionAndRespondAsync(string requestId, Permission
};

var result = await handler(permissionRequest, invocation);
if (result.Kind == new PermissionRequestResultKind("no-result"))
{
return;
}
await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, result);
}
catch (Exception)
Expand Down
1 change: 1 addition & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ public class PermissionRequestResult
/// <item><description><c>"denied-by-rules"</c> — denied by configured permission rules.</description></item>
/// <item><description><c>"denied-interactively-by-user"</c> — the user explicitly denied the request.</description></item>
/// <item><description><c>"denied-no-approval-rule-and-could-not-request-from-user"</c> — no rule matched and user approval was unavailable.</description></item>
/// <item><description><c>"no-result"</c> — leave the pending permission request unanswered.</description></item>
/// </list>
/// </summary>
[JsonPropertyName("kind")]
Expand Down
2 changes: 2 additions & 0 deletions dotnet/test/PermissionRequestResultKindTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public void WellKnownKinds_HaveExpectedValues()
Assert.Equal("denied-by-rules", PermissionRequestResultKind.DeniedByRules.Value);
Assert.Equal("denied-no-approval-rule-and-could-not-request-from-user", PermissionRequestResultKind.DeniedCouldNotRequestFromUser.Value);
Assert.Equal("denied-interactively-by-user", PermissionRequestResultKind.DeniedInteractivelyByUser.Value);
Assert.Equal("no-result", new PermissionRequestResultKind("no-result").Value);
Copy link
Contributor

Choose a reason for hiding this comment

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

Cross-SDK Consistency: .NET is missing behavioral tests for the no-result permission outcome.

TypeScript has comprehensive tests:

  • test/client.test.ts: "does not respond to v3 permission requests when handler returns no-result"
  • test/client.test.ts: "throws when a v2 permission handler returns no-result"

Python has:

  • test_client.py: "test_v2_permission_adapter_rejects_no_result"

Suggestion: Add tests to verify:

  1. When a v3 permission handler returns new PermissionRequestResultKind("no-result"), HandlePendingPermissionRequestAsync is NOT called
  2. When a v2 permission adapter receives no-result, it throws InvalidOperationException with message CopilotClient.NoResultPermissionV2ErrorMessage

Example test structure:

[Fact]
public async Task V3PermissionNoResult_DoesNotRespond()
{
    // Similar to nodejs test: verify HandlePendingPermissionRequestAsync is not called
}

[Fact]
public async Task V2PermissionAdapter_RejectsNoResult()
{
    // Verify OnPermissionRequestV2 throws InvalidOperationException when handler returns no-result
}

}

[Fact]
Expand Down Expand Up @@ -115,6 +116,7 @@ public void JsonRoundTrip_PreservesAllKinds()
PermissionRequestResultKind.DeniedByRules,
PermissionRequestResultKind.DeniedCouldNotRequestFromUser,
PermissionRequestResultKind.DeniedInteractivelyByUser,
new PermissionRequestResultKind("no-result"),
};

foreach (var kind in kinds)
Expand Down
5 changes: 5 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import (
"github.com/github/copilot-sdk/go/rpc"
)

const noResultPermissionV2Error = "permission handlers cannot return 'no-result' when connected to a protocol v2 server"
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

The v2 error message here is inconsistent with the other SDKs in this PR (casing + missing trailing period). Aligning the exact string across languages makes cross-SDK docs and tests easier to keep consistent.

Suggested change
const noResultPermissionV2Error = "permission handlers cannot return 'no-result' when connected to a protocol v2 server"
const noResultPermissionV2Error = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."

Copilot uses AI. Check for mistakes.

// Client manages the connection to the Copilot CLI server and provides session management.
//
// The Client can either spawn a CLI server process or connect to an existing server.
Expand Down Expand Up @@ -1531,6 +1533,9 @@ func (c *Client) handlePermissionRequestV2(req permissionRequestV2) (*permission
},
}, nil
}
if result.Kind == "no-result" {
return nil, &jsonrpc2.Error{Code: -32603, Message: noResultPermissionV2Error}
}

return &permissionResponseV2{Result: result}, nil
}
3 changes: 3 additions & 0 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,9 @@ func (s *Session) executePermissionAndRespond(requestID string, permissionReques
})
return
}
if result.Kind == "no-result" {
return
}

s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{
RequestID: requestID,
Expand Down
2 changes: 2 additions & 0 deletions go/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestPermissionRequestResultKind_Constants(t *testing.T) {
{"DeniedByRules", PermissionRequestResultKindDeniedByRules, "denied-by-rules"},
{"DeniedCouldNotRequestFromUser", PermissionRequestResultKindDeniedCouldNotRequestFromUser, "denied-no-approval-rule-and-could-not-request-from-user"},
{"DeniedInteractivelyByUser", PermissionRequestResultKindDeniedInteractivelyByUser, "denied-interactively-by-user"},
{"NoResult", PermissionRequestResultKind("no-result"), "no-result"},
Copy link
Contributor

Choose a reason for hiding this comment

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

Cross-SDK Consistency: Go is missing behavioral tests for the no-result permission outcome.

TypeScript has comprehensive tests:

  • test/client.test.ts: "does not respond to v3 permission requests when handler returns no-result"
  • test/client.test.ts: "throws when a v2 permission handler returns no-result"

Python has:

  • test_client.py: "test_v2_permission_adapter_rejects_no_result"

Suggestion: Add tests in Go to verify:

  1. When a v3 permission handler returns "no-result", HandlePendingPermissionRequest is NOT called
  2. When a v2 permission adapter receives "no-result", it returns a JSON-RPC error with the message "permission handlers cannot return 'no-result' when connected to a protocol v2 server"

This ensures the behavior is tested and won't regress.

}

for _, tt := range tests {
Expand Down Expand Up @@ -42,6 +43,7 @@ func TestPermissionRequestResult_JSONRoundTrip(t *testing.T) {
{"DeniedByRules", PermissionRequestResultKindDeniedByRules},
{"DeniedCouldNotRequestFromUser", PermissionRequestResultKindDeniedCouldNotRequestFromUser},
{"DeniedInteractivelyByUser", PermissionRequestResultKindDeniedInteractivelyByUser},
{"NoResult", PermissionRequestResultKind("no-result")},
{"Custom", PermissionRequestResultKind("custom")},
}

Expand Down
2 changes: 0 additions & 2 deletions nodejs/docs/agent-author.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,9 @@ Discovery rules:
## Minimal Skeleton

```js
import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

await joinSession({
onPermissionRequest: approveAll, // Required — handle permission requests
tools: [], // Optional — custom tools
hooks: {}, // Optional — lifecycle hooks
});
Expand Down
17 changes: 2 additions & 15 deletions nodejs/docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ A practical guide to writing extensions using the `@github/copilot-sdk` extensio
Every extension starts with the same boilerplate:

```js
import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

const session = await joinSession({
onPermissionRequest: approveAll,
hooks: { /* ... */ },
tools: [ /* ... */ ],
});
Expand All @@ -33,7 +31,6 @@ Use `session.log()` to surface messages to the user in the CLI timeline:

```js
const session = await joinSession({
onPermissionRequest: approveAll,
hooks: {
onSessionStart: async () => {
await session.log("My extension loaded");
Expand Down Expand Up @@ -383,7 +380,6 @@ function copyToClipboard(text) {
}

const session = await joinSession({
onPermissionRequest: approveAll,
hooks: {
onUserPromptSubmitted: async (input) => {
if (/\\bcopy\\b/i.test(input.prompt)) {
Expand Down Expand Up @@ -425,15 +421,12 @@ Correlate `tool.execution_start` / `tool.execution_complete` events by `toolCall
```js
import { existsSync, watchFile, readFileSync } from "node:fs";
import { join } from "node:path";
import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

const agentEdits = new Set(); // toolCallIds for in-flight agent edits
const recentAgentPaths = new Set(); // paths recently written by the agent

const session = await joinSession({
onPermissionRequest: approveAll,
});
const session = await joinSession();

const workspace = session.workspacePath; // e.g. ~/.copilot/session-state/<id>
if (workspace) {
Expand Down Expand Up @@ -480,14 +473,11 @@ Filter out agent edits by tracking `tool.execution_start` / `tool.execution_comp
```js
import { watch, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

const agentEditPaths = new Set();

const session = await joinSession({
onPermissionRequest: approveAll,
});
const session = await joinSession();

const cwd = process.cwd();
const IGNORE = new Set(["node_modules", ".git", "dist"]);
Expand Down Expand Up @@ -582,7 +572,6 @@ Register `onUserInputRequest` to enable the agent's `ask_user` tool:

```js
const session = await joinSession({
onPermissionRequest: approveAll,
onUserInputRequest: async (request) => {
// request.question has the agent's question
// request.choices has the options (if multiple choice)
Expand All @@ -599,7 +588,6 @@ An extension that combines tools, hooks, and events.

```js
import { execFile, exec } from "node:child_process";
import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

const isWindows = process.platform === "win32";
Expand All @@ -617,7 +605,6 @@ function openInEditor(filePath) {
}

const session = await joinSession({
onPermissionRequest: approveAll,
hooks: {
onUserPromptSubmitted: async (input) => {
if (/\\bcopy this\\b/i.test(input.prompt)) {
Expand Down
2 changes: 0 additions & 2 deletions nodejs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,9 @@ Extensions add custom tools, hooks, and behaviors to the Copilot CLI. They run a
Extensions use `@github/copilot-sdk` for all interactions with the CLI:

```js
import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

const session = await joinSession({
onPermissionRequest: approveAll,
tools: [
/* ... */
],
Expand Down
7 changes: 5 additions & 2 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from "vscode-jsonrpc/node.js";
import { createServerRpc } from "./generated/rpc.js";
import { getSdkProtocolVersion } from "./sdkProtocolVersion.js";
import { CopilotSession } from "./session.js";
import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js";
import type {
ConnectionState,
CopilotClientOptions,
Expand Down Expand Up @@ -1604,7 +1604,10 @@ export class CopilotClient {
try {
const result = await session._handlePermissionRequestV2(params.permissionRequest);
return { result };
} catch (_error) {
} catch (error) {
if (error instanceof Error && error.message === NO_RESULT_PERMISSION_V2_ERROR) {
throw error;
}
return {
result: {
kind: "denied-no-approval-rule-and-could-not-request-from-user",
Expand Down
19 changes: 12 additions & 7 deletions nodejs/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@

import { CopilotClient } from "./client.js";
import type { CopilotSession } from "./session.js";
import type { ResumeSessionConfig } from "./types.js";
import type { PermissionHandler, PermissionRequestResult, ResumeSessionConfig } from "./types.js";

const defaultJoinSessionPermissionHandler: PermissionHandler = (): PermissionRequestResult => ({
kind: "no-result",
});

export type JoinSessionConfig = Omit<ResumeSessionConfig, "onPermissionRequest"> & {
onPermissionRequest?: PermissionHandler;
};

/**
* Joins the current foreground session.
Expand All @@ -14,16 +22,12 @@ import type { ResumeSessionConfig } from "./types.js";
*
* @example
* ```typescript
* import { approveAll } from "@github/copilot-sdk";
* import { joinSession } from "@github/copilot-sdk/extension";
*
* const session = await joinSession({
* onPermissionRequest: approveAll,
* tools: [myTool],
* });
* const session = await joinSession({ tools: [myTool] });
* ```
*/
export async function joinSession(config: ResumeSessionConfig): Promise<CopilotSession> {
export async function joinSession(config: JoinSessionConfig = {}): Promise<CopilotSession> {
const sessionId = process.env.SESSION_ID;
if (!sessionId) {
throw new Error(
Expand All @@ -34,6 +38,7 @@ export async function joinSession(config: ResumeSessionConfig): Promise<CopilotS
const client = new CopilotClient({ isChildProcess: true });
return client.resumeSession(sessionId, {
...config,
onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler,
disableResume: config.disableResume ?? true,
});
}
14 changes: 13 additions & 1 deletion nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import type {
UserInputResponse,
} from "./types.js";

export const NO_RESULT_PERMISSION_V2_ERROR =
"Permission handlers cannot return 'no-result' when connected to a protocol v2 server.";

/** Assistant message event - the final response from the assistant. */
export type AssistantMessageEvent = Extract<SessionEvent, { type: "assistant.message" }>;

Expand Down Expand Up @@ -400,6 +403,9 @@ export class CopilotSession {
const result = await this.permissionHandler!(permissionRequest, {
sessionId: this.sessionId,
});
if (result.kind === "no-result") {
return;
}
await this.rpc.permissions.handlePendingPermissionRequest({ requestId, result });
} catch (_error) {
try {
Expand Down Expand Up @@ -505,8 +511,14 @@ export class CopilotSession {
const result = await this.permissionHandler(request as PermissionRequest, {
sessionId: this.sessionId,
});
if (result.kind === "no-result") {
throw new Error(NO_RESULT_PERMISSION_V2_ERROR);
}
return result;
} catch (_error) {
} catch (error) {
if (error instanceof Error && error.message === NO_RESULT_PERMISSION_V2_ERROR) {
throw error;
}
return { kind: "denied-no-approval-rule-and-could-not-request-from-user" };
}
}
Expand Down
3 changes: 2 additions & 1 deletion nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ export interface PermissionRequest {
import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js";

export type PermissionRequestResult =
SessionPermissionsHandlePendingPermissionRequestParams["result"];
| SessionPermissionsHandlePendingPermissionRequestParams["result"]
| { kind: "no-result" };

export type PermissionHandler = (
request: PermissionRequest,
Expand Down
32 changes: 32 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,38 @@ describe("CopilotClient", () => {
);
});

it("does not respond to v3 permission requests when handler returns no-result", async () => {
const client = new CopilotClient();
await client.start();
onTestFinished(() => client.forceStop());

const session = await client.createSession({
onPermissionRequest: () => ({ kind: "no-result" }),
});
const spy = vi.spyOn(session.rpc.permissions, "handlePendingPermissionRequest");

await (session as any)._executePermissionAndRespond("request-1", { kind: "write" });

expect(spy).not.toHaveBeenCalled();
});

it("throws when a v2 permission handler returns no-result", async () => {
const client = new CopilotClient();
await client.start();
onTestFinished(() => client.forceStop());

const session = await client.createSession({
onPermissionRequest: () => ({ kind: "no-result" }),
});

await expect(
(client as any).handlePermissionRequestV2({
sessionId: session.sessionId,
permissionRequest: { kind: "write" },
})
).rejects.toThrow(/protocol v2 server/);
});

it("forwards clientName in session.create request", async () => {
const client = new CopilotClient();
await client.start();
Expand Down
Loading
Loading