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
5 changes: 5 additions & 0 deletions .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"version": "v7.0.0",
"sha": "bbbca2ddaa5d8feaa63e36b76fdaad77386f024f"
},
"github/gh-aw/actions/setup@v0.50.5": {
"repo": "github/gh-aw/actions/setup",
"version": "v0.50.5",
"sha": "a7d371cc7e68f270ded0592942424548e05bf1c2"
},
"github/gh-aw/actions/setup@v0.52.1": {
"repo": "github/gh-aw/actions/setup",
"version": "v0.52.1",
Expand Down
7 changes: 7 additions & 0 deletions .github/dependabot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,32 @@ updates:
- package-ecosystem: 'github-actions'
directory: '/'
multi-ecosystem-group: 'all'
patterns: ['*']
- package-ecosystem: 'devcontainers'
directory: '/'
multi-ecosystem-group: 'all'
patterns: ['*']
# Node.js dependencies
- package-ecosystem: 'npm'
directory: '/nodejs'
multi-ecosystem-group: 'all'
patterns: ['*']
- package-ecosystem: 'npm'
directory: '/test/harness'
multi-ecosystem-group: 'all'
patterns: ['*']
# Python dependencies
- package-ecosystem: 'pip'
directory: '/python'
multi-ecosystem-group: 'all'
patterns: ['*']
# Go dependencies
- package-ecosystem: 'gomod'
directory: '/go'
multi-ecosystem-group: 'all'
patterns: ['*']
# .NET dependencies
- package-ecosystem: 'nuget'
directory: '/dotnet'
multi-ecosystem-group: 'all'
patterns: ['*']
1,377 changes: 1,377 additions & 0 deletions .github/workflows/cross-repo-issue-analysis.lock.yml

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions .github/workflows/cross-repo-issue-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
description: Analyzes copilot-sdk issues to determine if a fix is needed in copilot-agent-runtime, then opens a linked issue and suggested-fix PR there
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
Comment on lines +1 to +7
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

The PR title/description describe a documentation-only fix in the custom agents guide, but this PR also adds/changes multiple E2E tests and snapshots across Node/Python/Go/.NET and introduces a new GitHub Actions workflow. Please update the PR title/description to reflect the broader scope, or split unrelated changes into separate PRs to keep review and rollback risk manageable.

Copilot uses AI. Check for mistakes.
issue_number:
description: "Issue number to analyze"
required: true
type: string
permissions:
contents: read
issues: read
pull-requests: read
steps:
- name: Clone copilot-agent-runtime
run: git clone --depth 1 https://x-access-token:${{ secrets.RUNTIME_TRIAGE_TOKEN }}@github.com/github/copilot-agent-runtime.git ${{ github.workspace }}/copilot-agent-runtime
tools:
github:
toolsets: [default]
github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }}
edit:
bash:
- "grep:*"
- "find:*"
- "cat:*"
- "head:*"
- "tail:*"
- "wc:*"
- "ls:*"
safe-outputs:
github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }}
allowed-github-references: ["repo", "github/copilot-agent-runtime"]
add-comment:
max: 1
target: triggering
add-labels:
allowed: [runtime-fix-needed, sdk-fix-only, needs-investigation]
max: 3
target: triggering
create-issue:
title-prefix: "[copilot-sdk] "
labels: [upstream-from-sdk, ai-triaged]
target-repo: "github/copilot-agent-runtime"
max: 1
create-pull-request:
title-prefix: "[copilot-sdk] "
labels: [upstream-from-sdk, ai-suggested-fix]
draft: true
target-repo: "github/copilot-agent-runtime"

timeout-minutes: 20
---

# SDK Runtime Triage

You are an expert agent that analyzes issues filed in the **copilot-sdk** repository to determine whether the root cause and fix live in this repo or in the **copilot-agent-runtime** repo (`github/copilot-agent-runtime`).

## Context

- Repository: ${{ github.repository }}
- Issue number: ${{ github.event.issue.number || inputs.issue_number }}
- Issue title: ${{ github.event.issue.title }}

The **copilot-sdk** repo is a multi-language SDK (Node/TS, Python, Go, .NET) that communicates with the Copilot CLI via JSON-RPC. The **copilot-agent-runtime** repo contains the CLI/server that the SDK talks to. Many issues filed against the SDK are actually caused by behavior in the runtime.

## Your Task

### Step 1: Understand the Issue

Use GitHub tools to fetch the full issue body, comments, and any linked references for issue `${{ github.event.issue.number || inputs.issue_number }}` in `${{ github.repository }}`.

### Step 2: Analyze Against copilot-sdk

Search the copilot-sdk codebase on disk to understand whether the reported problem could originate here. The repo is checked out at the default working directory.

- Use bash tools (`grep`, `find`, `cat`) to search the relevant SDK language implementation (`nodejs/src/`, `python/copilot/`, `go/`, `dotnet/src/`)
- Look at the JSON-RPC client layer, session management, event handling, and tool definitions
- Check if the issue relates to SDK-side logic (type generation, streaming, event parsing, client options, etc.)

### Step 3: Investigate copilot-agent-runtime

If the issue does NOT appear to be caused by SDK code, or you suspect the runtime is involved, investigate the **copilot-agent-runtime** repo. It has been cloned to `./copilot-agent-runtime/` in the current working directory.

- Use bash tools (`grep`, `find`, `cat`) to search the runtime codebase at `./copilot-agent-runtime/`
- Look at the server-side JSON-RPC handling, session management, tool execution, and response generation
- Focus on the areas that correspond to the reported issue (e.g., if the issue is about streaming, look at the runtime's streaming implementation)

Common areas where runtime fixes are needed:
- JSON-RPC protocol handling and response formatting
- Session lifecycle (creation, persistence, compaction, destruction)
- Tool execution and permission handling
- Model/API interaction (prompt construction, response parsing)
- Streaming event generation (deltas, completions)
- Error handling and error response formatting

### Step 4: Make Your Determination

Classify the issue into one of these categories:

1. **SDK-fix-only**: The bug/feature is entirely in the SDK code. Label the issue `sdk-fix-only` and comment with your analysis.

2. **Runtime-fix-needed**: The root cause is in copilot-agent-runtime. Do ALL of the following:
- Label the original issue `runtime-fix-needed`
- Create an issue in `github/copilot-agent-runtime` that:
- Clearly describes the problem and root cause
- References the original SDK issue (e.g., `github/copilot-sdk#123`)
- Includes the specific files and code paths involved
- Suggests a fix approach
- Create a draft PR in `github/copilot-agent-runtime` with a suggested fix:
- Make the minimal, targeted code changes needed
- Include a clear PR description linking back to both issues
- If you're uncertain about the fix, still create the PR as a starting point for discussion
- Comment on the original SDK issue summarizing your findings and linking to the new runtime issue and PR

3. **Needs-investigation**: You cannot confidently determine the root cause. Label the issue `needs-investigation` and comment explaining what you found and what needs further human review.

## Guidelines

1. **Be thorough but focused**: Read enough code to be confident in your analysis, but don't read every file in both repos
2. **Err on the side of creating the runtime issue**: If there's a reasonable chance the fix is in the runtime, create the issue. False positives are better than missed upstream bugs.
3. **Make actionable PRs**: Even if the fix isn't perfect, a draft PR with a concrete starting point is more useful than just an issue description
4. **Link everything**: Always cross-reference between the SDK issue, runtime issue, and runtime PR so maintainers can follow the trail
5. **Be specific**: When describing the root cause, point to specific files, functions, and line numbers in both repos
6. **Don't duplicate**: Before creating a runtime issue, search existing open issues in `github/copilot-agent-runtime` to avoid duplicates. If a related issue exists, reference it instead of creating a new one.
4 changes: 2 additions & 2 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,13 @@ public IReadOnlyDictionary<string, string> GetEnvironment()
Cwd = WorkDir,
CliPath = GetCliPath(_repoRoot),
Environment = GetEnvironment(),
GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null,
GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) ? "fake-token-for-e2e-tests" : null,
});

public async ValueTask DisposeAsync()
{
// Skip writing snapshots in CI to avoid corrupting them on test failures
var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"));
var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"));
await _proxy.StopAsync(skipWritingCache: isCI);

try { if (Directory.Exists(HomeDir)) Directory.Delete(HomeDir, true); } catch { }
Expand Down
72 changes: 10 additions & 62 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ public async Task Should_Resume_A_Session_Using_The_Same_Client()
var answer2 = await TestHelper.GetFinalAssistantMessageAsync(session2);
Assert.NotNull(answer2);
Assert.Contains("2", answer2!.Data.Content ?? string.Empty);

// Can continue the conversation statefully
var answer3 = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" });
Assert.NotNull(answer3);
Assert.Contains("4", answer3!.Data.Content ?? string.Empty);
}

[Fact]
Expand All @@ -187,6 +192,11 @@ public async Task Should_Resume_A_Session_Using_A_New_Client()
var messages = await session2.GetMessagesAsync();
Assert.Contains(messages, m => m is UserMessageEvent);
Assert.Contains(messages, m => m is SessionResumeEvent);

// Can continue the conversation statefully
var answer2 = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" });
Assert.NotNull(answer2);
Assert.Contains("4", answer2!.Data.Content ?? string.Empty);
}

[Fact]
Expand Down Expand Up @@ -231,68 +241,6 @@ await session.SendAsync(new MessageOptions
Assert.Contains("4", answer!.Data.Content ?? string.Empty);
}

// TODO: This test requires the session-events.schema.json to include assistant.message_delta.
// The CLI v0.0.376 emits delta events at runtime, but the schema hasn't been updated yet.
// Once the schema is updated and types are regenerated, this test can be enabled.
[Fact(Skip = "Requires schema update for AssistantMessageDeltaEvent type")]
public async Task Should_Receive_Streaming_Delta_Events_When_Streaming_Is_Enabled()
{
var session = await CreateSessionAsync(new SessionConfig { Streaming = true });

var deltaContents = new List<string>();
var doneEvent = new TaskCompletionSource<bool>();

session.On(evt =>
{
switch (evt)
{
// TODO: Uncomment once AssistantMessageDeltaEvent is generated
// case AssistantMessageDeltaEvent delta:
// if (!string.IsNullOrEmpty(delta.Data.DeltaContent))
// deltaContents.Add(delta.Data.DeltaContent);
// break;
case SessionIdleEvent:
doneEvent.TrySetResult(true);
break;
}
});

await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" });

// Wait for completion
var completed = await Task.WhenAny(doneEvent.Task, Task.Delay(TimeSpan.FromSeconds(60)));
Assert.Equal(doneEvent.Task, completed);

// Should have received delta events
Assert.NotEmpty(deltaContents);

// Get the final message to compare
var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
Assert.NotNull(assistantMessage);

// Accumulated deltas should equal the final message
var accumulated = string.Join("", deltaContents);
Assert.Equal(assistantMessage!.Data.Content, accumulated);

// Final message should contain the answer
Assert.Contains("4", assistantMessage.Data.Content ?? string.Empty);
}

[Fact]
public async Task Should_Pass_Streaming_Option_To_Session_Creation()
{
// Verify that the streaming option is accepted without errors
var session = await CreateSessionAsync(new SessionConfig { Streaming = true });

Assert.Matches(@"^[a-f0-9-]+$", session.SessionId);

// Session should still work normally
await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
Assert.NotNull(assistantMessage);
Assert.Contains("2", assistantMessage!.Data.Content);
}

[Fact]
public async Task Should_Receive_Session_Events()
{
Expand Down
99 changes: 99 additions & 0 deletions dotnet/test/StreamingFidelityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

using GitHub.Copilot.SDK.Test.Harness;
using Xunit;
using Xunit.Abstractions;

namespace GitHub.Copilot.SDK.Test;

public class StreamingFidelityTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "streaming_fidelity", output)
{
[Fact]
public async Task Should_Produce_Delta_Events_When_Streaming_Is_Enabled()
{
var session = await CreateSessionAsync(new SessionConfig { Streaming = true });

var events = new List<SessionEvent>();
session.On(evt => events.Add(evt));

await session.SendAndWaitAsync(new MessageOptions { Prompt = "Count from 1 to 5, separated by commas." });

var types = events.Select(e => e.Type).ToList();

// Should have streaming deltas before the final message
var deltaEvents = events.OfType<AssistantMessageDeltaEvent>().ToList();
Assert.NotEmpty(deltaEvents);

// Deltas should have content
foreach (var delta in deltaEvents)
{
Assert.False(string.IsNullOrEmpty(delta.Data.DeltaContent));
}

// Should still have a final assistant.message
Assert.Contains("assistant.message", types);

// Deltas should come before the final message
var firstDeltaIdx = types.IndexOf("assistant.message_delta");
var lastAssistantIdx = types.LastIndexOf("assistant.message");
Assert.True(firstDeltaIdx < lastAssistantIdx);

await session.DisposeAsync();
}

[Fact]
public async Task Should_Not_Produce_Deltas_When_Streaming_Is_Disabled()
{
var session = await CreateSessionAsync(new SessionConfig { Streaming = false });

var events = new List<SessionEvent>();
session.On(evt => events.Add(evt));

await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say 'hello world'." });

var deltaEvents = events.OfType<AssistantMessageDeltaEvent>().ToList();

// No deltas when streaming is off
Assert.Empty(deltaEvents);

// But should still have a final assistant.message
var assistantEvents = events.OfType<AssistantMessageEvent>().ToList();
Assert.NotEmpty(assistantEvents);

await session.DisposeAsync();
}

[Fact]
public async Task Should_Produce_Deltas_After_Session_Resume()
{
var session = await CreateSessionAsync(new SessionConfig { Streaming = false });
await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3 + 6?" });
await session.DisposeAsync();

// Resume using a new client
using var newClient = Ctx.CreateClient();
var session2 = await newClient.ResumeSessionAsync(session.SessionId,
new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Streaming = true });

var events = new List<SessionEvent>();
session2.On(evt => events.Add(evt));

var answer = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" });
Assert.NotNull(answer);
Assert.Contains("18", answer!.Data.Content ?? string.Empty);

// Should have streaming deltas before the final message
var deltaEvents = events.OfType<AssistantMessageDeltaEvent>().ToList();
Assert.NotEmpty(deltaEvents);

// Deltas should have content
foreach (var delta in deltaEvents)
{
Assert.False(string.IsNullOrEmpty(delta.Data.DeltaContent));
}

await session2.DisposeAsync();
}
}
Loading
Loading