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
48 changes: 45 additions & 3 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,8 @@ public SessionCapabilities Capabilities
/// Canvas instances currently known to be open for this session.
/// </summary>
/// <remarks>
/// Populated from the most recent <c>session.create</c> / <c>session.resume</c>
/// response. This snapshot is not refreshed automatically when canvases open or
/// close after the session is established.
/// Populated from the most recent <c>session.resume</c> response and live
/// <c>session.canvas.opened</c> events.
/// </remarks>
[Experimental(Diagnostics.Experimental)]
public IReadOnlyList<OpenCanvasInstance> OpenCanvases => _openCanvases;
Expand Down Expand Up @@ -473,6 +472,8 @@ public IDisposable On<T>(Action<T> handler) where T : SessionEvent
/// </remarks>
internal void DispatchEvent(SessionEvent sessionEvent)
{
UpdateOpenCanvasesFromEvent(sessionEvent);

// Fire broadcast work concurrently (fire-and-forget with error logging).
// This is done outside the channel so broadcast handlers don't block the
// consumer loop — important when a secondary client's handler intentionally
Expand Down Expand Up @@ -889,6 +890,47 @@ internal void SetOpenCanvases(IList<OpenCanvasInstance>? canvases)
: Array.Empty<OpenCanvasInstance>();
}

private void UpdateOpenCanvasesFromEvent(SessionEvent sessionEvent)
{
if (sessionEvent is not SessionCanvasOpenedEvent canvasEvent)
return;

var data = canvasEvent.Data;
if (string.IsNullOrEmpty(data.InstanceId)
|| string.IsNullOrEmpty(data.CanvasId)
|| string.IsNullOrEmpty(data.ExtensionId)
|| string.IsNullOrEmpty(data.Availability.Value))
{
_logger.LogWarning("failed to deserialize session.canvas.opened payload");
return;
}

UpsertOpenCanvas(new OpenCanvasInstance
{
Availability = new CanvasInstanceAvailability(data.Availability.Value),
CanvasId = data.CanvasId,
ExtensionId = data.ExtensionId,
ExtensionName = data.ExtensionName,
Input = data.Input,
InstanceId = data.InstanceId,
Reopen = data.Reopen,
Status = data.Status,
Title = data.Title,
Url = data.Url,
});
}

private void UpsertOpenCanvas(OpenCanvasInstance canvas)
{
var canvases = _openCanvases.ToList();
var index = canvases.FindIndex(open => open.InstanceId == canvas.InstanceId);
if (index >= 0)
canvases[index] = canvas;
else
canvases.Add(canvas);
_openCanvases = canvases.AsReadOnly();
}

internal void SetCanvasHandler(ICanvasHandler? handler)
{
ClientSessionApis.Canvas = handler is null ? null : new CanvasHandlerAdapter(handler);
Expand Down
162 changes: 162 additions & 0 deletions dotnet/test/Unit/CanvasTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
*--------------------------------------------------------------------------------------------*/

using System;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GitHub.Copilot;
using GitHub.Copilot.Rpc;
using Microsoft.Extensions.Logging;
using Xunit;

namespace GitHub.Copilot.Test.Unit;
Expand All @@ -25,6 +27,76 @@ private static JsonSerializerOptions GetSerializerOptions()
return options!;
}

private static CopilotSession CreateSession()
{
var options = GetSerializerOptions();
var rpcType = typeof(CopilotClient).Assembly.GetType("GitHub.Copilot.JsonRpc");
Assert.NotNull(rpcType);

var inputStream = new MemoryStream();
var outputStream = new MemoryStream();
object? rpc;
try
{
rpc = Activator.CreateInstance(
rpcType!,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [inputStream, outputStream, options, null],
culture: null);
Assert.NotNull(rpc);
}
catch
{
inputStream.Dispose();
outputStream.Dispose();
throw;
}

var logger = new TestLogger();
var ctor = typeof(CopilotSession).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
types: [typeof(string), rpcType!, typeof(ILogger), typeof(CopilotClient), typeof(string)],
modifiers: null);
Assert.NotNull(ctor);
try
{
return (CopilotSession)ctor!.Invoke(["session-1", rpc, logger, new CopilotClient(), null]);
}
catch
{
inputStream.Dispose();
outputStream.Dispose();
throw;
}
}

private sealed class TestLogger : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => false;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
}
}

private static void DispatchEvent(CopilotSession session, SessionEvent evt)
{
var method = typeof(CopilotSession).GetMethod(
"DispatchEvent",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
method!.Invoke(session, [evt]);
}

[Fact]
public void CanvasDeclaration_Serializes_CamelCase_SkippingNulls()
{
Expand Down Expand Up @@ -67,6 +139,96 @@ public void CanvasProviderOpenResult_Roundtrips_WithCamelCaseFields()
Assert.Equal("ready", parsed.Status);
}

[Fact]
public void SessionCanvasOpenedEvent_UpdatesOpenCanvasSnapshots()
{
var session = CreateSession();

DispatchEvent(session, new SessionCanvasOpenedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasOpenedData
{
Availability = CanvasOpenedAvailability.Ready,
CanvasId = "",
ExtensionId = "project:counter",
InstanceId = "missing-canvas-id",
Reopen = false,
}
});
DispatchEvent(session, new SessionCanvasOpenedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasOpenedData
{
Availability = CanvasOpenedAvailability.Ready,
CanvasId = "counter",
ExtensionId = "project:counter",
ExtensionName = "Counter Provider",
InstanceId = "counter-1",
Title = "Counter",
Status = "ready",
Url = "https://example.test/counter",
Input = JsonDocument.Parse("""{"seed":1}""").RootElement.Clone(),
Reopen = false,
}
});
DispatchEvent(session, new SessionCanvasOpenedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasOpenedData
{
Availability = CanvasOpenedAvailability.Stale,
CanvasId = "logs",
ExtensionId = "project:logs",
InstanceId = "logs-1",
Title = "Logs",
Reopen = false,
}
});

Assert.Collection(
session.OpenCanvases,
canvas => Assert.Equal("counter-1", canvas.InstanceId),
canvas => Assert.Equal("logs-1", canvas.InstanceId));

DispatchEvent(session, new SessionCanvasOpenedEvent
{
Id = Guid.NewGuid(),
Timestamp = DateTimeOffset.UtcNow,
Data = new SessionCanvasOpenedData
{
Availability = CanvasOpenedAvailability.Stale,
CanvasId = "counter",
ExtensionId = "project:counter",
ExtensionName = "Counter Provider",
InstanceId = "counter-1",
Title = "Counter Updated",
Status = "reconnected",
Url = "https://example.test/counter-updated",
Input = JsonDocument.Parse("""{"seed":2}""").RootElement.Clone(),
Reopen = true,
}
});

Assert.Collection(
session.OpenCanvases,
canvas =>
{
Assert.Equal("counter-1", canvas.InstanceId);
Assert.Equal("Counter Updated", canvas.Title);
Assert.Equal("reconnected", canvas.Status);
Assert.Equal("https://example.test/counter-updated", canvas.Url);
Assert.True(canvas.Reopen);
Assert.Equal(CanvasInstanceAvailability.Stale, canvas.Availability);
Assert.Equal(2, canvas.Input!.Value.GetProperty("seed").GetInt32());
},
canvas => Assert.Equal("logs-1", canvas.InstanceId));
}

[Fact]
public void ExtensionInfo_Serializes_SourceAndName()
{
Expand Down
42 changes: 39 additions & 3 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ func (s *Session) WorkspacePath() string {
return s.workspacePath
}

// OpenCanvases returns the open-canvas snapshot last reported by the runtime
// (currently populated from the session.resume response). The returned slice
// is a copy and is safe to mutate by the caller.
// OpenCanvases returns the open-canvas snapshot last reported by the runtime.
// The snapshot is populated from session.resume and live session.canvas.opened
// events. The returned slice is a copy and is safe to mutate by the caller.
func (s *Session) OpenCanvases() []rpc.OpenCanvasInstance {
s.openCanvasesMu.RLock()
defer s.openCanvasesMu.RUnlock()
Expand All @@ -118,6 +118,41 @@ func (s *Session) setOpenCanvases(canvases []rpc.OpenCanvasInstance) {
s.openCanvases = canvases
}

func (s *Session) upsertOpenCanvas(canvas rpc.OpenCanvasInstance) {
s.openCanvasesMu.Lock()
defer s.openCanvasesMu.Unlock()
for i := range s.openCanvases {
if s.openCanvases[i].InstanceID == canvas.InstanceID {
s.openCanvases[i] = canvas
return
}
}
s.openCanvases = append(s.openCanvases, canvas)
}

func (s *Session) updateOpenCanvasesFromEvent(event SessionEvent) {
data, ok := event.Data.(*SessionCanvasOpenedData)
if !ok {
return
}
if data.InstanceID == "" || data.CanvasID == "" || data.ExtensionID == "" || data.Availability == "" {
fmt.Printf("failed to deserialize session.canvas.opened payload\n")
return
}
s.upsertOpenCanvas(rpc.OpenCanvasInstance{
Availability: rpc.CanvasInstanceAvailability(data.Availability),
CanvasID: data.CanvasID,
ExtensionID: data.ExtensionID,
ExtensionName: data.ExtensionName,
Input: data.Input,
InstanceID: data.InstanceID,
Reopen: data.Reopen,
Status: data.Status,
Title: data.Title,
URL: data.URL,
})
}

func (s *Session) registerCanvasHandler(handler CanvasHandler) {
s.canvasMu.Lock()
defer s.canvasMu.Unlock()
Expand Down Expand Up @@ -1110,6 +1145,7 @@ func fromRPCContent(value rpc.UIElicitationFieldValue) any {
// are delivered by a single consumer goroutine (processEvents), guaranteeing
// serial, FIFO dispatch without blocking the read loop.
func (s *Session) dispatchEvent(event SessionEvent) {
s.updateOpenCanvasesFromEvent(event)
go s.handleBroadcastEvent(event)

// Send to the event channel in a closure with a recover guard.
Expand Down
Loading
Loading