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
8 changes: 4 additions & 4 deletions dotnet/test/E2E/SessionE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,10 @@ await session.SendAsync(new MessageOptions
// Verify an abort event exists in messages
Assert.Contains(messages, m => m is AbortEvent);

// We should be able to send another message
var answer = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });
Assert.NotNull(answer);
Assert.Contains("4", answer!.Data.Content ?? string.Empty);
await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" });
var recoveryMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
Assert.NotNull(recoveryMessage);
Assert.Contains("4", recoveryMessage.Data.Content ?? string.Empty);
}

[Fact]
Expand Down
43 changes: 32 additions & 11 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;

namespace GitHub.Copilot.Test.Harness;
Expand Down Expand Up @@ -141,20 +142,40 @@ private static string GetCliPath(string repoRoot)
if (!string.IsNullOrEmpty(envPath)) return envPath;

// As of CLI 1.0.64-1 the @github/copilot package is a thin loader; the
// runnable index.js ships in the installed platform package
// (e.g. @github/copilot-linux-x64). Exactly one is installed.
// runnable index.js ships in the installed platform package.
var githubModules = Path.Join(repoRoot, "nodejs", "node_modules", "@github");
if (Directory.Exists(githubModules))
var packagePrefix = GetCliPackagePrefix();
var candidates = Directory.Exists(githubModules)
? Directory.EnumerateDirectories(githubModules, $"{packagePrefix}-*", SearchOption.TopDirectoryOnly)
.Select(directory => Path.Join(directory, "index.js"))
.Where(File.Exists)
.ToArray()
: [];

return candidates.Length switch
{
var candidate = Directory.EnumerateDirectories(githubModules, "copilot-*")
.Select(dir => Path.Join(dir, "index.js"))
.FirstOrDefault(File.Exists);
if (candidate != null)
return candidate;
}
1 => candidates[0],
0 => throw new InvalidOperationException(
$"CLI package matching '{packagePrefix}-*' not found under {githubModules}. " +
"Run 'npm install' in the nodejs directory first."),
_ => throw new InvalidOperationException(
$"Multiple CLI packages matching '{packagePrefix}-*' found under {githubModules}: " +
string.Join(", ", candidates.Select(Path.GetDirectoryName))),
};
}

throw new InvalidOperationException(
$"CLI not found under {githubModules}. Run 'npm install' in the nodejs directory first.");
private static string GetCliPackagePrefix()
{
var platform = OperatingSystem.IsWindows()
? "win32"
: OperatingSystem.IsMacOS()
? "darwin"
: OperatingSystem.IsLinux()
? RuntimeInformation.RuntimeIdentifier.StartsWith("linux-musl-", StringComparison.Ordinal)
? "linuxmusl"
: "linux"
: throw new PlatformNotSupportedException("Unsupported operating system for Copilot CLI E2E tests.");
return $"copilot-{platform}";
}

public async Task ConfigureForTestAsync(string testFile, [CallerMemberName] string? testName = null)
Expand Down
75 changes: 55 additions & 20 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ from copilot import CopilotClient
from copilot.session_events import AssistantMessageData, SessionIdleData
from copilot.session import PermissionHandler


async def main():
# Client automatically starts on enter and cleans up on exit
async with CopilotClient() as client:
Expand All @@ -100,6 +101,7 @@ async def main():
await session.send("What is 2+2?")
await done.wait()


asyncio.run(main())
```

Expand All @@ -114,6 +116,7 @@ from copilot import CopilotClient
from copilot.session_events import AssistantMessageData, SessionIdleData
from copilot.session import PermissionHandler


async def main():
client = CopilotClient()
await client.start()
Expand Down Expand Up @@ -141,6 +144,7 @@ async def main():
await session.disconnect()
await client.stop()


asyncio.run(main())
```

Expand All @@ -167,6 +171,7 @@ async with CopilotClient() as client:
on_permission_request=PermissionHandler.approve_all,
model="gpt-5",
) as session:

def on_event(event):
print(f"Event: {event.type}")

Expand Down Expand Up @@ -287,14 +292,18 @@ session_id = await client.get_foreground_session_id()
# Request TUI to display a specific session (TUI+server mode only)
await client.set_foreground_session_id("session-123")


# Subscribe to all lifecycle events
def on_lifecycle(event):
print(f"{event.type}: {event.session_id}")


unsubscribe = client.on_lifecycle(on_lifecycle)

# Subscribe to specific event type
unsubscribe = client.on_lifecycle("session.foreground", lambda e: print(f"Foreground: {e.session_id}"))
unsubscribe = client.on_lifecycle(
"session.foreground", lambda e: print(f"Foreground: {e.session_id}")
)

# Later, to stop receiving events:
unsubscribe()
Expand All @@ -316,14 +325,17 @@ Define tools with automatic JSON schema generation using the `@define_tool` deco
from pydantic import BaseModel, Field
from copilot import CopilotClient, define_tool


class LookupIssueParams(BaseModel):
id: str = Field(description="Issue identifier")


@define_tool(description="Fetch issue details from our tracker")
async def lookup_issue(params: LookupIssueParams) -> str:
issue = await fetch_issue(params.id)
return issue.summary


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
model="gpt-5",
Expand All @@ -343,6 +355,7 @@ from copilot import CopilotClient
from copilot.tools import Tool, ToolInvocation, ToolResult
from copilot.session import PermissionHandler


async def lookup_issue(invocation: ToolInvocation) -> ToolResult:
issue_id = invocation.arguments["id"]
issue = await fetch_issue(issue_id)
Expand All @@ -352,6 +365,7 @@ async def lookup_issue(invocation: ToolInvocation) -> ToolResult:
session_log=f"Fetched issue {issue_id}",
)


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
model="gpt-5",
Expand Down Expand Up @@ -471,6 +485,7 @@ from copilot.session_events import (
)
from copilot.session import PermissionHandler


async def main():
async with CopilotClient() as client:
async with await client.create_session(
Expand Down Expand Up @@ -507,6 +522,7 @@ async def main():
await session.send("Tell me a short story")
await done.wait() # Wait for streaming to complete


asyncio.run(main())
```

Expand Down Expand Up @@ -678,7 +694,10 @@ async with await client.create_session(
system_message={
"mode": "customize",
"sections": {
"tone": {"action": "replace", "content": "Respond in a warm, professional tone. Be thorough in explanations."},
"tone": {
"action": "replace",
"content": "Respond in a warm, professional tone. Be thorough in explanations.",
},
"code_change_rules": {"action": "remove"},
"guidelines": {"action": "append", "content": "\n* Always cite data sources"},
},
Expand All @@ -698,6 +717,7 @@ You can also pass a transform callback as the `action` instead of a string. The
def redact_paths(content: str) -> str:
return content.replace("/home/user", "/***")


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
model="gpt-5",
Expand Down Expand Up @@ -785,9 +805,7 @@ from copilot.rpc import (
from copilot.session_events import PermissionRequestShell


def on_permission_request(
request: PermissionRequest, invocation: dict
) -> PermissionRequestResult:
def on_permission_request(request: PermissionRequest, invocation: dict) -> PermissionRequestResult:
# ``PermissionRequest`` is a discriminated union — pattern-match on
# the variant class to access the per-kind fields.
match request:
Expand Down Expand Up @@ -871,6 +889,7 @@ async def handle_user_input(request, invocation):
"wasFreeform": True, # Whether the answer was freeform (not from choices)
}


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
model="gpt-5",
Expand All @@ -893,12 +912,14 @@ async def on_pre_tool_use(input, invocation):
"additionalContext": "Extra context for the model",
}


async def on_post_tool_use(input, invocation):
print(f"Tool {input['toolName']} completed")
return {
"additionalContext": "Post-execution notes",
}


async def on_post_tool_use_failure(input, invocation):
# Fires when a tool's result was a failure. `on_post_tool_use` only fires
# on success, so register this handler to observe failed tool calls. The
Expand All @@ -908,27 +929,32 @@ async def on_post_tool_use_failure(input, invocation):
"additionalContext": f"Retry guidance for {input['toolName']}",
}


async def on_user_prompt_submitted(input, invocation):
print(f"User prompt: {input['prompt']}")
return {
"modifiedPrompt": input["prompt"], # Optionally modify the prompt
}


async def on_session_start(input, invocation):
print(f"Session started from: {input['source']}") # "startup", "resume", "new"
return {
"additionalContext": "Session initialization context",
}


async def on_session_end(input, invocation):
print(f"Session ended: {input['reason']}")


async def on_error_occurred(input, invocation):
print(f"Error in {input['errorContext']}: {input['error']}")
return {
"errorHandling": "retry", # "retry", "skip", or "abort"
}


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
model="gpt-5",
Expand Down Expand Up @@ -962,13 +988,15 @@ Register slash commands that users can invoke from the CLI TUI. When the user ty
```python
from copilot.session import CommandDefinition, CommandContext, PermissionHandler


async def handle_deploy(ctx: CommandContext) -> None:
print(f"Deploying with args: {ctx.args}")
# ctx.session_id — the session where the command was invoked
# ctx.command — full command text (e.g. "/deploy production")
# ctx.command_name — command name without leading / (e.g. "deploy")
# ctx.args — raw argument string (e.g. "production")


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
commands=[
Expand Down Expand Up @@ -1030,29 +1058,34 @@ Shows a text input dialog with optional constraints:
name = await session.ui.input("Enter your name:")

# With options
email = await session.ui.input("Enter email:", {
"title": "Email Address",
"description": "We'll use this for notifications",
"format": "email",
})
email = await session.ui.input(
"Enter email:",
{
"title": "Email Address",
"description": "We'll use this for notifications",
"format": "email",
},
)
```

### Custom Elicitation

For full control, use the `elicitation()` method with a custom JSON schema:

```python
result = await session.ui.elicitation({
"message": "Configure deployment",
"requestedSchema": {
"type": "object",
"properties": {
"region": {"type": "string", "enum": ["us-east-1", "eu-west-1"]},
"replicas": {"type": "number", "minimum": 1, "maximum": 10},
result = await session.ui.elicitation(
{
"message": "Configure deployment",
"requestedSchema": {
"type": "object",
"properties": {
"region": {"type": "string", "enum": ["us-east-1", "eu-west-1"]},
"replicas": {"type": "number", "minimum": 1, "maximum": 10},
},
"required": ["region"],
},
"required": ["region"],
},
})
}
)

if result["action"] == "accept":
region = result["content"]["region"]
Expand All @@ -1066,6 +1099,7 @@ When the server (or an MCP tool) needs to ask the end-user a question, it sends
```python
from copilot.session import ElicitationContext, ElicitationResult, PermissionHandler


async def handle_elicitation(
context: ElicitationContext,
) -> ElicitationResult:
Expand All @@ -1082,6 +1116,7 @@ async def handle_elicitation(
"content": {"answer": "yes"},
}


async with await client.create_session(
on_permission_request=PermissionHandler.approve_all,
on_elicitation_request=handle_elicitation,
Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ telemetry = [

[dependency-groups]
dev = [
"ruff>=0.1.0",
"ruff==0.16.0",
"ty>=0.0.2,<0.0.25",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
Expand Down
Loading