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
12 changes: 3 additions & 9 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2834,16 +2834,12 @@ public async ValueTask DisposeAsync()

private class RpcHandler(CopilotClient client)
{
public void OnSessionEvent(string sessionId, JsonElement? @event)
public void OnSessionEvent(string sessionId, SessionEvent? @event)
{
var session = client.GetSession(sessionId);
if (session != null && @event != null)
{
var evt = SessionEvent.FromJson(@event.Value.GetRawText());
if (evt != null)
{
session.DispatchEvent(evt);
}
session.DispatchEvent(@event);
}
}

Expand All @@ -2863,9 +2859,7 @@ public void OnSessionLifecycle(string type, string sessionId, JsonElement? metad
evt.SessionId = sessionId;
if (metadata is not null)
{
evt.Metadata = JsonSerializer.Deserialize(
metadata.Value.GetRawText(),
TypesJsonContext.Default.SessionLifecycleEventMetadata);
evt.Metadata = metadata.Value.Deserialize(TypesJsonContext.Default.SessionLifecycleEventMetadata);
}

client.DispatchLifecycleEvent(evt);
Expand Down
54 changes: 31 additions & 23 deletions dotnet/src/JsonRpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,22 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken)

// Parse the raw JSON. Body is at buffer[0..contentLength], carried bytes
// for the next message are at buffer[contentLength..contentLength+carried].
JsonElement? message = null;
try
{
using var doc = JsonDocument.Parse(buffer.AsMemory(0, contentLength));
message = doc.RootElement.Clone();
var parsed = doc.RootElement;

// Route while the document is alive. Incoming method arguments are
// materialized synchronously before dispatch can become asynchronous.
if (parsed.TryGetProperty("id", out var idProp) && !parsed.TryGetProperty("method", out _))
{
// It's a response to one of our requests.
HandleResponse(parsed, idProp);
}
else if (parsed.TryGetProperty("method", out var methodProp) && methodProp.GetString() is string methodName)
{
_ = HandleIncomingMethodAsync(methodName, parsed, cancellationToken);
}
}
catch (JsonException ex)
{
Expand All @@ -311,21 +322,6 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken)
buffer = retainedBuffer;
}

if (message is not { } parsed)
{
continue;
}

// Route the message
if (parsed.TryGetProperty("id", out var idProp) && !parsed.TryGetProperty("method", out _))
{
// It's a response to one of our requests
HandleResponse(parsed, idProp);
}
else if (parsed.TryGetProperty("method", out var methodProp) && methodProp.GetString() is string methodName)
{
_ = HandleIncomingMethodAsync(methodName, parsed, cancellationToken);
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
Expand Down Expand Up @@ -469,7 +465,7 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken)

private void HandleResponse(JsonElement message, JsonElement idProp)
{
if (!idProp.TryGetInt64(out long id))
if (idProp.ValueKind != JsonValueKind.Number || !idProp.TryGetInt64(out long id))
{
return;
}
Expand Down Expand Up @@ -528,7 +524,8 @@ private async Task HandleIncomingMethodAsync(string methodName, JsonElement mess
JsonElement? requestId = null;
if (message.TryGetProperty("id", out var idProp))
{
requestId = idProp;
// Requests may outlive the parsed message while an asynchronous handler runs.
requestId = idProp.Clone();
}

if (!_methods.TryGetValue(methodName, out var registration))
Expand All @@ -544,7 +541,10 @@ private async Task HandleIncomingMethodAsync(string methodName, JsonElement mess

try
{
var result = await InvokeHandlerAsync(registration, paramsProp, cancellationToken).ConfigureAwait(false);
// Materialize arguments before the first possible suspension so none of
// them borrow from the JsonDocument owned by the read loop.
var invokeArgs = DeserializeHandlerArguments(registration, paramsProp, cancellationToken);
var result = await InvokeHandlerAsync(registration, invokeArgs).ConfigureAwait(false);

if (requestId.HasValue)
{
Expand Down Expand Up @@ -599,11 +599,13 @@ await SendResultResponseAsync(
}
}

private async ValueTask<object?> InvokeHandlerAsync(MethodRegistration registration, JsonElement paramsProp, CancellationToken cancellationToken)
private object?[] DeserializeHandlerArguments(
MethodRegistration registration,
JsonElement paramsProp,
CancellationToken cancellationToken)
{
var parameters = registration.Parameters;

// Build argument list
var invokeArgs = new object?[parameters.Length];

if (registration.SingleObjectParam)
Expand Down Expand Up @@ -681,7 +683,13 @@ await SendResultResponseAsync(
$"Unsupported JSON-RPC params shape '{paramsProp.ValueKind}' for handler with positional parameters.");
}

// Invoke
return invokeArgs;
}

private static async ValueTask<object?> InvokeHandlerAsync(
MethodRegistration registration,
object?[] invokeArgs)
{
var result = registration.Handler.DynamicInvoke(invokeArgs);

// Handlers return one of: a synchronous value, Task (void async), or ValueTask<T>.
Expand Down
68 changes: 68 additions & 0 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,59 @@ public async Task Raw_SendAsync_MessageSource_Remains_Available(string? source)
AssertMessageSource(Assert.Single(server.Requests, request => request.Method == "session.send").Params, source);
}

[Fact]
public async Task SessionEvents_Recover_From_Malformed_Input_And_Isolate_Multiple_Handlers()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
await using var session = await client.CreateSessionAsync(new SessionConfig());
var firstHandlerEvents = new List<string>();
var secondHandlerEvents = new List<SessionEvent>();
var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

using var firstSubscription = session.On<SessionEvent>(@event =>
{
firstHandlerEvents.Add(@event.Type);
throw new InvalidOperationException("Expected test handler failure.");
});
using var secondSubscription = session.On<SessionEvent>(@event =>
{
secondHandlerEvents.Add(@event);
if (secondHandlerEvents.Count == 2)
{
received.TrySetResult();
}
});

await server.SendSessionEventPayloadAsync(session.SessionId, 42);
await server.SendSessionEventPayloadAsync(session.SessionId, new Dictionary<string, object?>
{
["id"] = Guid.NewGuid().ToString(),
["timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
["parentId"] = null,
["type"] = "future.event",
["data"] = new object?[] { null, false, 42, "text", new Dictionary<string, object?> { ["nested"] = true } }
});
await server.SendSessionEventAsync(session.SessionId, "tool.execution_start", new()
{
["toolCallId"] = "tool-1",
["toolName"] = "view",
["arguments"] = new Dictionary<string, object?>
{
["path"] = "README.md",
["nested"] = new object?[] { null, false, 42, "text", new Dictionary<string, object?> { ["value"] = true } }
}
});

await received.Task.WaitAsync(TimeSpan.FromSeconds(5));

Assert.Equal(["unknown", "tool.execution_start"], firstHandlerEvents);
Assert.IsType<SessionEvent>(secondHandlerEvents[0]);
var toolEvent = Assert.IsType<ToolExecutionStartEvent>(secondHandlerEvents[1]);
Assert.Equal("README.md", toolEvent.Data.Arguments?.GetProperty("path").GetString());
Assert.True(toolEvent.Data.Arguments?.GetProperty("nested")[4].GetProperty("value").GetBoolean());
}

public static IEnumerable<object?[]> MessageSourcesAndOutcomes
{
get
Expand Down Expand Up @@ -2510,6 +2563,21 @@ public Task SendSessionEventAsync(string sessionId, string type, Dictionary<stri
}, _cts.Token);
}

public Task SendSessionEventPayloadAsync(string sessionId, object? @event)
{
var stream = _stream ?? throw new InvalidOperationException("Client is not connected.");
return WriteMessageAsync(stream, new Dictionary<string, object?>
{
["jsonrpc"] = "2.0",
["method"] = "session.event",
["params"] = new Dictionary<string, object?>
{
["sessionId"] = sessionId,
["event"] = @event
}
}, _cts.Token);
}

public async Task SendAndDrainSessionEventAsync(
CopilotSession session,
string type,
Expand Down
Loading
Loading