Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 == PermissionRequestResultKind.NoResult)
{
throw new InvalidOperationException(NoResultPermissionV2ErrorMessage);
}
return new PermissionRequestResponseV2(result);
Comment on lines 1399 to 1404

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

The v2 adapter now throws when a permission handler returns NoResult, but the .NET test suite here only validates the new kind constant. Adding a test that drives OnPermissionRequestV2 (or the underlying session handler) and asserts that NoResult causes an exception would lock in the intended “fail loudly” behavior.

Copilot uses AI. Check for mistakes.
}
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/PermissionHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ public static class PermissionHandler
/// <summary>A <see cref="PermissionRequestHandler"/> that approves all permission requests.</summary>
public static PermissionRequestHandler ApproveAll { get; } =
(_, _) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });

/// <summary>A <see cref="PermissionRequestHandler"/> that leaves permission requests unanswered.</summary>
public static PermissionRequestHandler NoResult { get; } =
(_, _) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.NoResult });
}
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 == PermissionRequestResultKind.NoResult)
{
return;
}
await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, result);
}
catch (Exception)
Expand Down
4 changes: 4 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ public class ToolInvocation
/// <summary>Gets the kind indicating the permission was denied interactively by the user.</summary>
public static PermissionRequestResultKind DeniedInteractivelyByUser { get; } = new("denied-interactively-by-user");

/// <summary>Gets the kind indicating the SDK should not answer the pending permission request.</summary>
public static PermissionRequestResultKind NoResult { get; } = new("no-result");

/// <summary>Gets the underlying string value of this <see cref="PermissionRequestResultKind"/>.</summary>
public string Value => _value ?? string.Empty;

Expand Down Expand Up @@ -350,6 +353,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", PermissionRequestResultKind.NoResult.Value);
}

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

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"

Copilot AI Mar 12, 2026

Copy link

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 == PermissionRequestResultKindNoResult {
return nil, &jsonrpc2.Error{Code: -32603, Message: noResultPermissionV2Error}
}

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

New behavior: v2 permission adapters now fail loudly when a handler returns no-result, but there is no Go test covering this path. Adding a unit test that exercises handlePermissionRequestV2 with a PermissionHandler.NoResult session would prevent regressions and confirm the JSON-RPC error message/code.

Copilot uses AI. Check for mistakes.

return &permissionResponseV2{Result: result}, nil
}
5 changes: 5 additions & 0 deletions go/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ package copilot
var PermissionHandler = struct {
// ApproveAll approves all permission requests.
ApproveAll PermissionHandlerFunc
// NoResult leaves the pending permission request unanswered.
NoResult PermissionHandlerFunc
}{
ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) {
return PermissionRequestResult{Kind: PermissionRequestResultKindApproved}, nil
},
NoResult: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) {
return PermissionRequestResult{Kind: PermissionRequestResultKindNoResult}, 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 == PermissionRequestResultKindNoResult {
return
}

s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{
RequestID: requestID,
Expand Down
3 changes: 3 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ const (

// PermissionRequestResultKindDeniedInteractivelyByUser indicates the permission was denied interactively by the user.
PermissionRequestResultKindDeniedInteractivelyByUser PermissionRequestResultKind = "denied-interactively-by-user"

// PermissionRequestResultKindNoResult indicates the SDK should not answer the pending permission request.
PermissionRequestResultKindNoResult PermissionRequestResultKind = "no-result"
)

Copy link
Copy Markdown
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 a named constant for "no-result".

Other SDKs provide well-known constants/static properties for all permission result kinds:

  • .NET has PermissionRequestResultKind.Approved, .DeniedByRules, .DeniedInteractivelyByUser, etc.
  • Python has "no-result" in the PermissionRequestResultKind literal
  • TypeScript uses a union type that includes { kind: "no-result" }

Suggestion: Add a named constant for consistency:

// PermissionRequestResultKindNoResult indicates the permission handler chose not to respond
PermissionRequestResultKindNoResult PermissionRequestResultKind = "no-result"

This would align with Go's existing pattern and make the constant available for developers (e.g., in extensions that want to explicitly return this kind).


// PermissionRequestResult represents the result of a permission request
Expand Down
12 changes: 12 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", PermissionRequestResultKindNoResult, "no-result"},
}

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", PermissionRequestResultKindNoResult},
{"Custom", PermissionRequestResultKind("custom")},
}

Expand Down Expand Up @@ -89,3 +91,13 @@ func TestPermissionRequestResult_JSONSerialize(t *testing.T) {
t.Errorf("expected %s, got %s", expected, string(data))
}
}

func TestPermissionHandler_NoResult(t *testing.T) {
result, err := PermissionHandler.NoResult(PermissionRequest{}, PermissionInvocation{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Kind != PermissionRequestResultKindNoResult {
t.Errorf("expected %q, got %q", PermissionRequestResultKindNoResult, result.Kind)
}
}
5 changes: 4 additions & 1 deletion nodejs/docs/agent-author.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,13 @@ 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
});

// Optional — provide this if your extension should actively answer
// permission requests instead of leaving them pending for another client.
await joinSession({ onPermissionRequest: approveAll });
```

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

This skeleton example calls joinSession() twice; the second call reads like an in-place override but actually attaches a new client again. It would be clearer to present the onPermissionRequest override as an alternative snippet or as part of the initial joinSession({ ... }) options.

Copilot uses AI. Check for mistakes.

---
Expand Down
5 changes: 4 additions & 1 deletion nodejs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,17 @@ import { approveAll } from "@github/copilot-sdk";
import { joinSession } from "@github/copilot-sdk/extension";

const session = await joinSession({
onPermissionRequest: approveAll,
tools: [
/* ... */
],
hooks: {
/* ... */
},
});

// Optional: override the default "no result" behavior if this extension
// should actively answer permission requests itself.
await joinSession({ onPermissionRequest: approveAll });

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

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

The example calls joinSession() a second time to override permission handling, which is misleading: it creates a second client/session attachment rather than changing the options of the already-joined session. Consider showing this as an alternative snippet (e.g., "// or") or include onPermissionRequest in the original joinSession({ ... }) call.

Suggested change
});
// Optional: override the default "no result" behavior if this extension
// should actively answer permission requests itself.
await joinSession({ onPermissionRequest: approveAll });
// Optional: override the default "no result" behavior if this extension
// should actively answer permission requests itself.
onPermissionRequest: approveAll,
});

Copilot uses AI. Check for mistakes.
```

The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information.
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
15 changes: 8 additions & 7 deletions nodejs/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

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

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

/**
* Joins the current foreground session.
Expand All @@ -14,16 +18,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 +34,7 @@ export async function joinSession(config: ResumeSessionConfig): Promise<CopilotS
const client = new CopilotClient({ isChildProcess: true });
return client.resumeSession(sessionId, {
...config,
onPermissionRequest: config.onPermissionRequest ?? noResult,
disableResume: config.disableResume ?? true,
});
}
2 changes: 1 addition & 1 deletion nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

export { CopilotClient } from "./client.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export { defineTool, approveAll } from "./types.js";
export { defineTool, approveAll, noResult } from "./types.js";
export type {
ConnectionState,
CopilotClientOptions,
Expand Down
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
8 changes: 7 additions & 1 deletion nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,21 @@ export interface PermissionRequest {

import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js";

export interface NoResultPermissionRequestResult {
kind: "no-result";
}

export type PermissionRequestResult =
SessionPermissionsHandlePendingPermissionRequestParams["result"];
| SessionPermissionsHandlePendingPermissionRequestParams["result"]
| NoResultPermissionRequestResult;

export type PermissionHandler = (
request: PermissionRequest,
invocation: { sessionId: string }
) => Promise<PermissionRequestResult> | PermissionRequestResult;

export const approveAll: PermissionHandler = () => ({ kind: "approved" });
export const noResult: PermissionHandler = () => ({ kind: "no-result" });

// ============================================================================
// User Input Request Types
Expand Down
30 changes: 29 additions & 1 deletion nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { approveAll, CopilotClient, type ModelInfo } from "../src/index.js";
import { approveAll, CopilotClient, noResult, type ModelInfo } from "../src/index.js";

// This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead

Expand All @@ -26,6 +26,34 @@ 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: noResult });
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: noResult });

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