Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,23 @@ Send a message to the session.
- `Prompt` - The message/prompt to send
- `Attachments` - File attachments
- `Mode` - Delivery mode ("enqueue" or "immediate")
- `Source` - Optional message origin: `MessageSource.User` or `MessageSource.System`. Omitted by default, preserving the runtime's default user behavior.

Returns the message ID.

Use `MessageSource.System` for application-generated context. This marks the
message's origin; it does not replace the session's system prompt or change
delivery mode. `SendAndWaitAsync` accepts the same option and still waits for
session idle, returning null if no assistant message was received.

```csharp
await session.SendAsync(new MessageOptions
{
Prompt = "The background build completed successfully.",
Source = MessageSource.System,
});
```

##### `On(Action<SessionEvent> handler): IDisposable`

Subscribe to session events. Returns a disposable to unsubscribe.
Expand Down
2 changes: 2 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ public async Task<string> SendAsync(MessageOptions options, CancellationToken ca
Attachments = options.Attachments,
Mode = options.Mode,
AgentMode = options.AgentMode,
Source = options.Source,
Traceparent = traceparent,
Tracestate = tracestate,
RequestHeaders = options.RequestHeaders,
Expand Down Expand Up @@ -2270,6 +2271,7 @@ internal record SendMessageRequest
public string? Mode { get; init; }
[JsonPropertyName("agentMode")]
public AgentMode? AgentMode { get; init; }
public MessageSource? Source { get; init; }
public string? Traceparent { get; init; }
public string? Tracestate { get; init; }
public IDictionary<string, string>? RequestHeaders { get; init; }
Expand Down
20 changes: 20 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2133,6 +2133,20 @@ public enum AgentMode
Shell
}

/// <summary>
/// Identifies the origin of a message sent to a session.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<MessageSource>))]
public enum MessageSource
{
/// <summary>The message originates from user input.</summary>
[JsonStringEnumMemberName("user")]
User,
/// <summary>The message provides application-generated context.</summary>
[JsonStringEnumMemberName("system")]
System
}

/// <summary>
/// Specifies the operation to perform on a system message section.
/// </summary>
Expand Down Expand Up @@ -4029,6 +4043,7 @@ private MessageOptions(MessageOptions? other)
Attachments = other.Attachments is not null ? [.. other.Attachments] : null;
Mode = other.Mode;
AgentMode = other.AgentMode;
Source = other.Source;
Prompt = other.Prompt;
DisplayPrompt = other.DisplayPrompt;
RequestHeaders = other.RequestHeaders is not null
Expand All @@ -4055,6 +4070,11 @@ private MessageOptions(MessageOptions? other)
/// </summary>
public AgentMode? AgentMode { get; set; }
/// <summary>
/// The message's origin. When unset, the field is omitted and the runtime defaults to user input.
/// This tags message provenance; it does not replace the session's system prompt or change delivery mode.
/// </summary>
public MessageSource? Source { get; set; }
/// <summary>
/// Custom per-turn HTTP headers for outbound model requests.
/// </summary>
public IDictionary<string, string>? RequestHeaders { get; set; }
Expand Down
254 changes: 254 additions & 0 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,217 @@ public async Task Generated_Session_Rpc_Throws_When_Session_Disposed()
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.Rpc.Model.GetCurrentAsync());
}

[Fact]
public async Task SendAsync_MessageSource_Is_Omitted_By_Default()
{
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 options = new MessageOptions { Prompt = "User input" };
Assert.Null(options.Source);
Assert.Null(options.Clone().Source);

await session.SendAsync(options);
await session.SendAsync("More user input");

var requests = server.Requests.Where(request => request.Method == "session.send").ToArray();
Assert.Equal(2, requests.Length);
Assert.All(requests, request => AssertMessageSource(request.Params, null));
}

[Theory]
[InlineData(null, null)]
[InlineData(MessageSource.User, "user")]
[InlineData(MessageSource.System, "system")]
public async Task SendAsync_MessageSource_Preserves_Other_Options(MessageSource? source, string? wireSource)
{
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 options = new MessageOptions { Prompt = "Background context", Source = source };

Assert.Equal("message-1", await session.SendAsync(options));
var request = Assert.Single(server.Requests, request => request.Method == "session.send").Params;
AssertMessageSource(request, wireSource);
foreach (var property in new[] { "mode", "agentMode", "attachments", "displayPrompt", "requestHeaders" })
{
Assert.False(request.TryGetProperty(property, out _));
}

using var activity = new Activity("message-source-test").SetIdFormat(ActivityIdFormat.W3C);
activity.TraceStateString = "test=message-source";
activity.Start();

foreach (var mode in new[] { "enqueue", "immediate" })
{
server.ClearRequests();
options.Mode = mode;
options.AgentMode = AgentMode.Plan;
options.DisplayPrompt = "Background update";
options.Attachments = [new AttachmentFile { Path = "/context.txt", DisplayName = "context.txt" }];
options.RequestHeaders = new Dictionary<string, string> { ["X-Test"] = "source-parity" };

Assert.Equal("message-1", await session.SendAsync(options));

request = Assert.Single(server.Requests, request => request.Method == "session.send").Params;
AssertMessageSource(request, wireSource);
Assert.Equal(session.SessionId, request.GetProperty("sessionId").GetString());
Assert.Equal(options.Prompt, request.GetProperty("prompt").GetString());
Assert.Equal(mode, request.GetProperty("mode").GetString());
Assert.Equal("plan", request.GetProperty("agentMode").GetString());
Assert.Equal(options.DisplayPrompt, request.GetProperty("displayPrompt").GetString());
Assert.Equal("source-parity", request.GetProperty("requestHeaders").GetProperty("X-Test").GetString());
var attachment = Assert.Single(request.GetProperty("attachments").EnumerateArray());
Assert.Equal("file", attachment.GetProperty("type").GetString());
Assert.Equal("/context.txt", attachment.GetProperty("path").GetString());
Assert.Equal("context.txt", attachment.GetProperty("displayName").GetString());
Assert.Equal(activity.Id, request.GetProperty("traceparent").GetString());
Assert.Equal(activity.TraceStateString, request.GetProperty("tracestate").GetString());
Assert.Equal(source, options.Source);
}
}

[Theory]
[InlineData(null)]
[InlineData("user")]
[InlineData("system")]
public async Task Raw_SendAsync_MessageSource_Remains_Available(string? source)
{
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 result = await session.Rpc.SendAsync("Context", source: source);

Assert.Equal("message-1", result.MessageId);
AssertMessageSource(Assert.Single(server.Requests, request => request.Method == "session.send").Params, source);
}

[Theory]
[InlineData(null, false)]
[InlineData(null, true)]
[InlineData(MessageSource.User, false)]
[InlineData(MessageSource.User, true)]
[InlineData(MessageSource.System, false)]
[InlineData(MessageSource.System, true)]
public async Task SendAndWaitAsync_MessageSource_Completes_On_Idle(MessageSource? source, bool hasAssistantMessage)
{
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 assistantReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var subscription = session.On<AssistantMessageEvent>(_ => assistantReceived.TrySetResult());

var sendTask = session.SendAndWaitAsync(new MessageOptions { Prompt = "Context", Source = source });
var request = await WaitForRequestAsync(server, "session.send");
AssertMessageSource(request.Params, source?.ToString().ToLowerInvariant());

if (hasAssistantMessage)
{
await server.SendSessionEventAsync(session.SessionId, "assistant.message", new()
{
["messageId"] = "assistant-1",
["content"] = "Acknowledged"
});
await assistantReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
Assert.False(sendTask.IsCompleted);

await server.SendSessionEventAsync(session.SessionId, "session.idle", new());
var result = await sendTask.WaitAsync(TimeSpan.FromSeconds(5));

if (hasAssistantMessage)
{
Assert.NotNull(result);
Assert.Equal("Acknowledged", result.Data.Content);
}
else
{
Assert.Null(result);
}
}

[Theory]
[InlineData(null, false)]
[InlineData(null, true)]
[InlineData(MessageSource.User, false)]
[InlineData(MessageSource.User, true)]
[InlineData(MessageSource.System, false)]
[InlineData(MessageSource.System, true)]
public async Task SendAndWaitAsync_MessageSource_Propagates_Errors(MessageSource? source, bool rpcError)
{
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());
if (rpcError)
{
server.FailSessionSend();
}

var sendTask = session.SendAndWaitAsync(new MessageOptions { Prompt = "Context", Source = source });
var request = await WaitForRequestAsync(server, "session.send");
AssertMessageSource(request.Params, source?.ToString().ToLowerInvariant());

if (rpcError)
{
var error = await Assert.ThrowsAsync<IOException>(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Contains("session send failed", error.Message);
}
else
{
await server.SendSessionEventAsync(session.SessionId, "session.error", new()
{
["errorType"] = "query",
["message"] = "model request failed"
});
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Equal("Session error: model request failed", error.Message);
}
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SendAndWaitAsync_MessageSource_Preserves_Timeout_And_Cancellation(bool cancel)
{
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());
using var cancellation = new CancellationTokenSource();

var sendTask = session.SendAndWaitAsync(
new MessageOptions { Prompt = "Context", Source = MessageSource.System },
timeout: cancel ? TimeSpan.FromSeconds(30) : TimeSpan.FromMilliseconds(50),
cancellationToken: cancellation.Token);
await WaitForRequestAsync(server, "session.send");

if (cancel)
{
cancellation.Cancel();
var error = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Equal(cancellation.Token, error.CancellationToken);
}
else
{
var error = await Assert.ThrowsAsync<TimeoutException>(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Contains("SendAndWaitAsync timed out", error.Message);
}
}

private static void AssertMessageSource(JsonElement request, string? source)
{
if (source is null)
{
Assert.False(request.TryGetProperty("source", out _));
}
else
{
Assert.Equal(source, request.GetProperty("source").GetString());
}
Assert.False(request.TryGetProperty("billable", out _));
Assert.False(request.TryGetProperty("wait", out _));
}

[Fact]
public async Task SendAndWaitAsync_Skips_Autopilot_Continuation_Idle()
{
Expand Down Expand Up @@ -1960,6 +2171,7 @@ private sealed class FakeCopilotServer : IAsyncDisposable
private bool _delayDestroy;
private bool _failRuntimeShutdown;
private bool _failSessionCreate;
private bool _failSessionSend;

private FakeCopilotServer(TcpListener listener)
{
Expand Down Expand Up @@ -2026,6 +2238,11 @@ public void FailSessionCreate()
_failSessionCreate = true;
}

public void FailSessionSend()
{
_failSessionSend = true;
}

public void CloseConnection()
{
_stream?.Dispose();
Expand All @@ -2051,6 +2268,28 @@ public async Task<JsonElement> SendRequestAsync(string method, Dictionary<string
return await completion.Task.WaitAsync(_cts.Token);
}

public Task SendSessionEventAsync(string sessionId, string type, Dictionary<string, object?> data)
{
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"] = new Dictionary<string, object?>
{
["id"] = Guid.NewGuid().ToString(),
["timestamp"] = DateTimeOffset.UtcNow.ToString("O"),
["parentId"] = null,
["type"] = type,
["data"] = data
}
}
}, _cts.Token);
}

public async ValueTask DisposeAsync()
{
_allowDestroy.TrySetResult();
Expand Down Expand Up @@ -2154,6 +2393,21 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
}, cancellationToken);
return;
}
if (method == "session.send" && _failSessionSend)
{
_failSessionSend = false;
await WriteMessageAsync(stream, new Dictionary<string, object?>
{
["jsonrpc"] = "2.0",
["id"] = id,
["error"] = new Dictionary<string, object?>
{
["code"] = -32000,
["message"] = "session send failed"
}
}, cancellationToken);
return;
}
object? result = method switch
{
"connect" => new Dictionary<string, object?>
Expand Down
Loading
Loading