Skip to content

Commit 7b5565d

Browse files
jmoseleyCopilot
andauthored
Track live open canvas snapshots (#1447)
* Track live open canvas snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Track live open canvas snapshots across SDKs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: fix resume race and broadcast ordering - rust: upsert resume snapshots instead of wholesale replace, so live session.canvas.opened notifications received during session.resume are preserved. - rust: update capabilities and open canvas snapshots BEFORE broadcasting the event, so subscribers observe current state when handling the event. - dotnet: dispose MemoryStreams on construction failure in test helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 644b84b commit 7b5565d

10 files changed

Lines changed: 713 additions & 26 deletions

File tree

dotnet/src/Session.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,8 @@ public SessionCapabilities Capabilities
131131
/// Canvas instances currently known to be open for this session.
132132
/// </summary>
133133
/// <remarks>
134-
/// Populated from the most recent <c>session.create</c> / <c>session.resume</c>
135-
/// response. This snapshot is not refreshed automatically when canvases open or
136-
/// close after the session is established.
134+
/// Populated from the most recent <c>session.resume</c> response and live
135+
/// <c>session.canvas.opened</c> events.
137136
/// </remarks>
138137
[Experimental(Diagnostics.Experimental)]
139138
public IReadOnlyList<OpenCanvasInstance> OpenCanvases => _openCanvases;
@@ -473,6 +472,8 @@ public IDisposable On<T>(Action<T> handler) where T : SessionEvent
473472
/// </remarks>
474473
internal void DispatchEvent(SessionEvent sessionEvent)
475474
{
475+
UpdateOpenCanvasesFromEvent(sessionEvent);
476+
476477
// Fire broadcast work concurrently (fire-and-forget with error logging).
477478
// This is done outside the channel so broadcast handlers don't block the
478479
// consumer loop — important when a secondary client's handler intentionally
@@ -889,6 +890,47 @@ internal void SetOpenCanvases(IList<OpenCanvasInstance>? canvases)
889890
: Array.Empty<OpenCanvasInstance>();
890891
}
891892

893+
private void UpdateOpenCanvasesFromEvent(SessionEvent sessionEvent)
894+
{
895+
if (sessionEvent is not SessionCanvasOpenedEvent canvasEvent)
896+
return;
897+
898+
var data = canvasEvent.Data;
899+
if (string.IsNullOrEmpty(data.InstanceId)
900+
|| string.IsNullOrEmpty(data.CanvasId)
901+
|| string.IsNullOrEmpty(data.ExtensionId)
902+
|| string.IsNullOrEmpty(data.Availability.Value))
903+
{
904+
_logger.LogWarning("failed to deserialize session.canvas.opened payload");
905+
return;
906+
}
907+
908+
UpsertOpenCanvas(new OpenCanvasInstance
909+
{
910+
Availability = new CanvasInstanceAvailability(data.Availability.Value),
911+
CanvasId = data.CanvasId,
912+
ExtensionId = data.ExtensionId,
913+
ExtensionName = data.ExtensionName,
914+
Input = data.Input,
915+
InstanceId = data.InstanceId,
916+
Reopen = data.Reopen,
917+
Status = data.Status,
918+
Title = data.Title,
919+
Url = data.Url,
920+
});
921+
}
922+
923+
private void UpsertOpenCanvas(OpenCanvasInstance canvas)
924+
{
925+
var canvases = _openCanvases.ToList();
926+
var index = canvases.FindIndex(open => open.InstanceId == canvas.InstanceId);
927+
if (index >= 0)
928+
canvases[index] = canvas;
929+
else
930+
canvases.Add(canvas);
931+
_openCanvases = canvases.AsReadOnly();
932+
}
933+
892934
internal void SetCanvasHandler(ICanvasHandler? handler)
893935
{
894936
ClientSessionApis.Canvas = handler is null ? null : new CanvasHandlerAdapter(handler);

dotnet/test/Unit/CanvasTests.cs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
*--------------------------------------------------------------------------------------------*/
44

55
using System;
6+
using System.IO;
67
using System.Reflection;
78
using System.Text.Json;
89
using System.Threading;
910
using System.Threading.Tasks;
1011
using GitHub.Copilot;
1112
using GitHub.Copilot.Rpc;
13+
using Microsoft.Extensions.Logging;
1214
using Xunit;
1315

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

30+
private static CopilotSession CreateSession()
31+
{
32+
var options = GetSerializerOptions();
33+
var rpcType = typeof(CopilotClient).Assembly.GetType("GitHub.Copilot.JsonRpc");
34+
Assert.NotNull(rpcType);
35+
36+
var inputStream = new MemoryStream();
37+
var outputStream = new MemoryStream();
38+
object? rpc;
39+
try
40+
{
41+
rpc = Activator.CreateInstance(
42+
rpcType!,
43+
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
44+
binder: null,
45+
args: [inputStream, outputStream, options, null],
46+
culture: null);
47+
Assert.NotNull(rpc);
48+
}
49+
catch
50+
{
51+
inputStream.Dispose();
52+
outputStream.Dispose();
53+
throw;
54+
}
55+
56+
var logger = new TestLogger();
57+
var ctor = typeof(CopilotSession).GetConstructor(
58+
BindingFlags.Instance | BindingFlags.NonPublic,
59+
binder: null,
60+
types: [typeof(string), rpcType!, typeof(ILogger), typeof(CopilotClient), typeof(string)],
61+
modifiers: null);
62+
Assert.NotNull(ctor);
63+
try
64+
{
65+
return (CopilotSession)ctor!.Invoke(["session-1", rpc, logger, new CopilotClient(), null]);
66+
}
67+
catch
68+
{
69+
inputStream.Dispose();
70+
outputStream.Dispose();
71+
throw;
72+
}
73+
}
74+
75+
private sealed class TestLogger : ILogger
76+
{
77+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
78+
79+
public bool IsEnabled(LogLevel logLevel) => false;
80+
81+
public void Log<TState>(
82+
LogLevel logLevel,
83+
EventId eventId,
84+
TState state,
85+
Exception? exception,
86+
Func<TState, Exception?, string> formatter)
87+
{
88+
}
89+
}
90+
91+
private static void DispatchEvent(CopilotSession session, SessionEvent evt)
92+
{
93+
var method = typeof(CopilotSession).GetMethod(
94+
"DispatchEvent",
95+
BindingFlags.Instance | BindingFlags.NonPublic);
96+
Assert.NotNull(method);
97+
method!.Invoke(session, [evt]);
98+
}
99+
28100
[Fact]
29101
public void CanvasDeclaration_Serializes_CamelCase_SkippingNulls()
30102
{
@@ -67,6 +139,96 @@ public void CanvasProviderOpenResult_Roundtrips_WithCamelCaseFields()
67139
Assert.Equal("ready", parsed.Status);
68140
}
69141

142+
[Fact]
143+
public void SessionCanvasOpenedEvent_UpdatesOpenCanvasSnapshots()
144+
{
145+
var session = CreateSession();
146+
147+
DispatchEvent(session, new SessionCanvasOpenedEvent
148+
{
149+
Id = Guid.NewGuid(),
150+
Timestamp = DateTimeOffset.UtcNow,
151+
Data = new SessionCanvasOpenedData
152+
{
153+
Availability = CanvasOpenedAvailability.Ready,
154+
CanvasId = "",
155+
ExtensionId = "project:counter",
156+
InstanceId = "missing-canvas-id",
157+
Reopen = false,
158+
}
159+
});
160+
DispatchEvent(session, new SessionCanvasOpenedEvent
161+
{
162+
Id = Guid.NewGuid(),
163+
Timestamp = DateTimeOffset.UtcNow,
164+
Data = new SessionCanvasOpenedData
165+
{
166+
Availability = CanvasOpenedAvailability.Ready,
167+
CanvasId = "counter",
168+
ExtensionId = "project:counter",
169+
ExtensionName = "Counter Provider",
170+
InstanceId = "counter-1",
171+
Title = "Counter",
172+
Status = "ready",
173+
Url = "https://example.test/counter",
174+
Input = JsonDocument.Parse("""{"seed":1}""").RootElement.Clone(),
175+
Reopen = false,
176+
}
177+
});
178+
DispatchEvent(session, new SessionCanvasOpenedEvent
179+
{
180+
Id = Guid.NewGuid(),
181+
Timestamp = DateTimeOffset.UtcNow,
182+
Data = new SessionCanvasOpenedData
183+
{
184+
Availability = CanvasOpenedAvailability.Stale,
185+
CanvasId = "logs",
186+
ExtensionId = "project:logs",
187+
InstanceId = "logs-1",
188+
Title = "Logs",
189+
Reopen = false,
190+
}
191+
});
192+
193+
Assert.Collection(
194+
session.OpenCanvases,
195+
canvas => Assert.Equal("counter-1", canvas.InstanceId),
196+
canvas => Assert.Equal("logs-1", canvas.InstanceId));
197+
198+
DispatchEvent(session, new SessionCanvasOpenedEvent
199+
{
200+
Id = Guid.NewGuid(),
201+
Timestamp = DateTimeOffset.UtcNow,
202+
Data = new SessionCanvasOpenedData
203+
{
204+
Availability = CanvasOpenedAvailability.Stale,
205+
CanvasId = "counter",
206+
ExtensionId = "project:counter",
207+
ExtensionName = "Counter Provider",
208+
InstanceId = "counter-1",
209+
Title = "Counter Updated",
210+
Status = "reconnected",
211+
Url = "https://example.test/counter-updated",
212+
Input = JsonDocument.Parse("""{"seed":2}""").RootElement.Clone(),
213+
Reopen = true,
214+
}
215+
});
216+
217+
Assert.Collection(
218+
session.OpenCanvases,
219+
canvas =>
220+
{
221+
Assert.Equal("counter-1", canvas.InstanceId);
222+
Assert.Equal("Counter Updated", canvas.Title);
223+
Assert.Equal("reconnected", canvas.Status);
224+
Assert.Equal("https://example.test/counter-updated", canvas.Url);
225+
Assert.True(canvas.Reopen);
226+
Assert.Equal(CanvasInstanceAvailability.Stale, canvas.Availability);
227+
Assert.Equal(2, canvas.Input!.Value.GetProperty("seed").GetInt32());
228+
},
229+
canvas => Assert.Equal("logs-1", canvas.InstanceId));
230+
}
231+
70232
[Fact]
71233
public void ExtensionInfo_Serializes_SourceAndName()
72234
{

go/session.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ func (s *Session) WorkspacePath() string {
9898
return s.workspacePath
9999
}
100100

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

121+
func (s *Session) upsertOpenCanvas(canvas rpc.OpenCanvasInstance) {
122+
s.openCanvasesMu.Lock()
123+
defer s.openCanvasesMu.Unlock()
124+
for i := range s.openCanvases {
125+
if s.openCanvases[i].InstanceID == canvas.InstanceID {
126+
s.openCanvases[i] = canvas
127+
return
128+
}
129+
}
130+
s.openCanvases = append(s.openCanvases, canvas)
131+
}
132+
133+
func (s *Session) updateOpenCanvasesFromEvent(event SessionEvent) {
134+
data, ok := event.Data.(*SessionCanvasOpenedData)
135+
if !ok {
136+
return
137+
}
138+
if data.InstanceID == "" || data.CanvasID == "" || data.ExtensionID == "" || data.Availability == "" {
139+
fmt.Printf("failed to deserialize session.canvas.opened payload\n")
140+
return
141+
}
142+
s.upsertOpenCanvas(rpc.OpenCanvasInstance{
143+
Availability: rpc.CanvasInstanceAvailability(data.Availability),
144+
CanvasID: data.CanvasID,
145+
ExtensionID: data.ExtensionID,
146+
ExtensionName: data.ExtensionName,
147+
Input: data.Input,
148+
InstanceID: data.InstanceID,
149+
Reopen: data.Reopen,
150+
Status: data.Status,
151+
Title: data.Title,
152+
URL: data.URL,
153+
})
154+
}
155+
121156
func (s *Session) registerCanvasHandler(handler CanvasHandler) {
122157
s.canvasMu.Lock()
123158
defer s.canvasMu.Unlock()
@@ -1110,6 +1145,7 @@ func fromRPCContent(value rpc.UIElicitationFieldValue) any {
11101145
// are delivered by a single consumer goroutine (processEvents), guaranteeing
11111146
// serial, FIFO dispatch without blocking the read loop.
11121147
func (s *Session) dispatchEvent(event SessionEvent) {
1148+
s.updateOpenCanvasesFromEvent(event)
11131149
go s.handleBroadcastEvent(event)
11141150

11151151
// Send to the event channel in a closure with a recover guard.

0 commit comments

Comments
 (0)