From 310da23e05aab2acb3cffacd0117fdb2a781f022 Mon Sep 17 00:00:00 2001 From: ylvic Date: Thu, 11 Jun 2026 10:16:06 +0800 Subject: [PATCH 1/2] feat: real SSE streaming, CancellationToken, file attachments, Mcp Remove/Update, typed ConfigClient --- src/NOpenCode/AskOperation.cs | 23 +++-- src/NOpenCode/Clients/ConfigClient.cs | 5 + src/NOpenCode/Clients/McpClient.cs | 10 ++ src/NOpenCode/Http/NOpenCodeHttpClient.cs | 17 ++++ src/NOpenCode/Models/Part.cs | 3 + src/NOpenCode/NOpenCode.csproj | 3 +- src/NOpenCode/OpenCodeSession.cs | 55 ++++++++--- tests/NOpenCode.Tests/ModelTests.cs | 5 +- tests/NOpenCode.Tests/NewApiTests.cs | 114 ++++++++++++++++++++++ tests/NOpenCode.Tests/StreamingTests.cs | 104 ++++++++++++++++++++ 10 files changed, 317 insertions(+), 22 deletions(-) create mode 100644 tests/NOpenCode.Tests/NewApiTests.cs create mode 100644 tests/NOpenCode.Tests/StreamingTests.cs diff --git a/src/NOpenCode/AskOperation.cs b/src/NOpenCode/AskOperation.cs index 9894457..5643e5f 100644 --- a/src/NOpenCode/AskOperation.cs +++ b/src/NOpenCode/AskOperation.cs @@ -39,16 +39,18 @@ public AskOperation WithFiles(params string[] files) return this; } - public async Task Execute() + public async Task Execute(CancellationToken ct = default) { - var reply = await ExecuteFull(); + var reply = await ExecuteFull(ct); return reply.GetText(); } - public async Task ExecuteFull() + public async Task ExecuteFull(CancellationToken ct = default) { - var session = await CreateSessionAsync(); - var reply = await session.Ask(_prompt); + var session = await CreateSessionAsync(ct); + var reply = _files.Count > 0 + ? await session.Ask(_prompt, opts => opts.Files = _files) + : await session.Ask(_prompt); await session.Delete(); return reply; } @@ -59,18 +61,21 @@ public async Task AskStream( Action? onError = null, CancellationToken ct = default) { - var session = await CreateSessionAsync(); - await session.AskStream(_prompt, null, onChunk, onComplete, onError, ct); + var session = await CreateSessionAsync(ct); + if (_files.Count > 0) + await session.AskStream(_prompt, opts => opts.Files = _files, onChunk, onComplete, onError, ct); + else + await session.AskStream(_prompt, null, onChunk, onComplete, onError, ct); } - private async Task CreateSessionAsync() + private async Task CreateSessionAsync(CancellationToken ct = default) { var title = _prompt.Length > 80 ? _prompt.Substring(0, 80) + "..." : _prompt; var body = new { title }; - var result = await _http.Post("/session", body); + var result = await _http.Post("/session", body, ct); return new OpenCodeSession(_http, _config, result.Id); } } diff --git a/src/NOpenCode/Clients/ConfigClient.cs b/src/NOpenCode/Clients/ConfigClient.cs index ad0d74f..ec884e7 100644 --- a/src/NOpenCode/Clients/ConfigClient.cs +++ b/src/NOpenCode/Clients/ConfigClient.cs @@ -21,5 +21,10 @@ public async Task Update(object patch, CancellationToken ct = d { return await _http.Patch("/config", patch, ct); } + + public async Task Update(NOpenCodeConfig config, CancellationToken ct = default) + { + return await _http.Patch("/config", config, ct); + } } } diff --git a/src/NOpenCode/Clients/McpClient.cs b/src/NOpenCode/Clients/McpClient.cs index 9b9db60..06d6b23 100644 --- a/src/NOpenCode/Clients/McpClient.cs +++ b/src/NOpenCode/Clients/McpClient.cs @@ -23,5 +23,15 @@ public async Task Add(string name, object config, CancellationToken c var body = new { name, config }; return await _http.Post("/mcp", body, ct); } + + public async Task Remove(string name, CancellationToken ct = default) + { + await _http.Delete($"/mcp/{name}", ct); + } + + public async Task Update(string name, object config, CancellationToken ct = default) + { + return await _http.Patch($"/mcp/{name}", config, ct); + } } } diff --git a/src/NOpenCode/Http/NOpenCodeHttpClient.cs b/src/NOpenCode/Http/NOpenCodeHttpClient.cs index 48299dc..fac28e8 100644 --- a/src/NOpenCode/Http/NOpenCodeHttpClient.cs +++ b/src/NOpenCode/Http/NOpenCodeHttpClient.cs @@ -88,6 +88,23 @@ public async Task GetStream(string path, CancellationToken ct = default) return await response.Content.ReadAsStreamAsync(); } + public async Task PostStream(string path, object? body = null, CancellationToken ct = default) + { + var content = SerializeBody(body); + var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = content ?? new StringContent("", Encoding.UTF8, "application/json") + }; + var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + throw new NOpenCodeRequestException( + path, $"Server returned {response.StatusCode}", (int)response.StatusCode, responseBody); + } + return await response.Content.ReadAsStreamAsync(); + } + public async IAsyncEnumerable ReadSse(string path, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) { using var request = new HttpRequestMessage(HttpMethod.Get, path); diff --git a/src/NOpenCode/Models/Part.cs b/src/NOpenCode/Models/Part.cs index 4d51768..1437e54 100644 --- a/src/NOpenCode/Models/Part.cs +++ b/src/NOpenCode/Models/Part.cs @@ -18,5 +18,8 @@ public class Part [JsonPropertyName("result")] public string? Result { get; set; } + + [JsonPropertyName("uri")] + public string? Uri { get; set; } } } diff --git a/src/NOpenCode/NOpenCode.csproj b/src/NOpenCode/NOpenCode.csproj index fb00163..2c52538 100644 --- a/src/NOpenCode/NOpenCode.csproj +++ b/src/NOpenCode/NOpenCode.csproj @@ -3,7 +3,7 @@ netstandard2.0 NOpenCode - 0.2.2 + 0.3.0 ylvict Empower your .NET applications with OpenCode's AI engine. Express your intent in natural language — NOpenCode bridges your application logic with AI. opencode;ai;sdk;llm;chat;copilot;assistant;dotnet;csharp;natural-language;mcp;sse;integration;automation @@ -20,6 +20,7 @@ + diff --git a/src/NOpenCode/OpenCodeSession.cs b/src/NOpenCode/OpenCodeSession.cs index ac7599b..6bda0c0 100644 --- a/src/NOpenCode/OpenCodeSession.cs +++ b/src/NOpenCode/OpenCodeSession.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -59,23 +61,43 @@ public async Task AskStream( configure?.Invoke(options); var body = BuildMessageDict(message, options); + body["stream"] = true; try { - var fullReply = await _http.Post( + using var stream = await _http.PostStream( $"/session/{_sessionId}/message", body, ct); - var text = fullReply.GetText(); - if (!string.IsNullOrEmpty(text)) + using var reader = new SseReader(stream); + OpenCodeReply? finalReply = null; + + while (!ct.IsCancellationRequested) { - onChunk(text); + var evt = await reader.ReadEventAsync(ct); + if (evt == null) break; + + if (evt.Type == "chunk") + { + var part = JsonSerializer.Deserialize(evt.Data); + if (part?.Type == "text" && part.Text != null) + onChunk(part.Text); + } + else if (evt.Type == "complete") + { + finalReply = JsonSerializer.Deserialize(evt.Data); + } + else if (evt.Type == "error") + { + throw new NOpenCodeException( + $"Server streaming error: {evt.Data}"); + } } - onComplete?.Invoke(fullReply); + onComplete?.Invoke(finalReply ?? new OpenCodeReply()); } - catch (Exception ex) + catch (Exception ex) when (onError != null) { - onError?.Invoke(ex); + onError(ex); } } @@ -162,13 +184,24 @@ private JsonObject BuildMessageBody(string message, MessageOptions? options = nu private JsonObject BuildMessageDict(string message, MessageOptions? options) { - var body = new JsonObject + var parts = new JsonArray + { + new JsonObject { ["type"] = "text", ["text"] = message } + }; + + if (options?.Files != null) { - ["parts"] = new JsonArray + foreach (var file in options.Files) { - new JsonObject { ["type"] = "text", ["text"] = message } + parts.Add(new JsonObject + { + ["type"] = "file", + ["uri"] = file + }); } - }; + } + + var body = new JsonObject { ["parts"] = parts }; var modelId = options?.Model ?? _config.Model; if (modelId != null) diff --git a/tests/NOpenCode.Tests/ModelTests.cs b/tests/NOpenCode.Tests/ModelTests.cs index c70c8ad..91eb54c 100644 --- a/tests/NOpenCode.Tests/ModelTests.cs +++ b/tests/NOpenCode.Tests/ModelTests.cs @@ -112,6 +112,7 @@ public void DefaultConstructor_SetsDefaults() Assert.Null(part.ToolName); Assert.Null(part.ToolArgs); Assert.Null(part.Result); + Assert.Null(part.Uri); } [Fact] @@ -123,13 +124,15 @@ public void Properties_AreSettable() Text = "output", ToolName = "bash", ToolArgs = "echo hi", - Result = "hi" + Result = "hi", + Uri = "file:///path/to/file.cs" }; Assert.Equal("toolUse", part.Type); Assert.Equal("output", part.Text); Assert.Equal("bash", part.ToolName); Assert.Equal("echo hi", part.ToolArgs); Assert.Equal("hi", part.Result); + Assert.Equal("file:///path/to/file.cs", part.Uri); } } diff --git a/tests/NOpenCode.Tests/NewApiTests.cs b/tests/NOpenCode.Tests/NewApiTests.cs new file mode 100644 index 0000000..59248bc --- /dev/null +++ b/tests/NOpenCode.Tests/NewApiTests.cs @@ -0,0 +1,114 @@ +using System.Reflection; + +namespace NOpenCode.Tests; + +public class NewApiTests +{ + [Fact] + public void AskOperation_Execute_AcceptsCancellationToken() + { + var method = typeof(AskOperation).GetMethod("Execute", new[] { typeof(CancellationToken) }); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + [Fact] + public void AskOperation_ExecuteFull_AcceptsCancellationToken() + { + var method = typeof(AskOperation).GetMethod("ExecuteFull", new[] { typeof(CancellationToken) }); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + [Fact] + public void AskOperation_WithFiles_StoresFiles() + { + var op = (AskOperation)Activator.CreateInstance( + typeof(AskOperation), + BindingFlags.NonPublic | BindingFlags.Instance, + null, + new object?[] { null!, null!, "test prompt" }, + null)!; + var result = op.WithFiles("file1.cs", "file2.cs"); + Assert.Same(op, result); + } + + [Fact] + public void McpClient_HasRemoveMethod() + { + var method = typeof(McpClient).GetMethod("Remove", new[] { typeof(string), typeof(CancellationToken) }); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + [Fact] + public void McpClient_HasUpdateMethod() + { + var method = typeof(McpClient).GetMethod("Update", new[] { typeof(string), typeof(object), typeof(CancellationToken) }); + Assert.NotNull(method); + Assert.Equal(typeof(Task), method!.ReturnType); + } + + [Fact] + public void ConfigClient_HasTypedUpdateOverload() + { + var methods = typeof(ConfigClient).GetMethods() + .Where(m => m.Name == "Update") + .ToList(); + var typedOverload = methods.FirstOrDefault(m => + { + var p = m.GetParameters(); + return p.Length == 2 && p[0].ParameterType == typeof(NOpenCodeConfig); + }); + Assert.NotNull(typedOverload); + } + + [Fact] + public void Part_HasUriProperty() + { + var prop = typeof(Part).GetProperty("Uri"); + Assert.NotNull(prop); + Assert.Equal(typeof(string), prop!.PropertyType); + } + + [Fact] + public void MessageOptions_FilesPropertyExists() + { + var prop = typeof(MessageOptions).GetProperty("Files"); + Assert.NotNull(prop); + Assert.Equal(typeof(List), prop!.PropertyType); + } + + [Fact] + public void OpenCodeSession_AskStream_UsesPostStream() + { + var methods = typeof(OpenCodeSession).GetMethods() + .Where(m => m.Name == "AskStream") + .ToList(); + Assert.Equal(2, methods.Count); + + var withConfigure = methods.FirstOrDefault(m => + { + var p = m.GetParameters(); + return p.Length >= 2 && p[1].ParameterType == typeof(Action); + }); + Assert.NotNull(withConfigure); + + var withoutConfigure = methods.FirstOrDefault(m => + { + var p = m.GetParameters(); + return p[0].ParameterType == typeof(string) && + p[1].ParameterType == typeof(Action); + }); + Assert.NotNull(withoutConfigure); + } + + [Fact] + public void ConfigClient_HasBothUpdateOverloads() + { + var methods = typeof(ConfigClient).GetMethods() + .Where(m => m.Name == "Update" && m.DeclaringType == typeof(ConfigClient)) + .ToArray(); + Assert.Equal(2, methods.Length); + } +} diff --git a/tests/NOpenCode.Tests/StreamingTests.cs b/tests/NOpenCode.Tests/StreamingTests.cs new file mode 100644 index 0000000..74baf75 --- /dev/null +++ b/tests/NOpenCode.Tests/StreamingTests.cs @@ -0,0 +1,104 @@ +using System.IO; +using System.Text; +using System.Text.Json; + +namespace NOpenCode.Tests; + +public class SseReaderTests +{ + [Fact] + public async Task ReadEventAsync_ReturnsNull_WhenStreamEmpty() + { + using var stream = new MemoryStream(); + using var reader = new SseReader(stream); + var evt = await reader.ReadEventAsync(); + Assert.Null(evt); + } + + [Fact] + public async Task ReadEventAsync_ReadsChunkEvent() + { + var sse = "event: chunk\ndata: {\"type\":\"text\",\"text\":\"Hello\"}\n\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sse)); + using var reader = new SseReader(stream); + var evt = await reader.ReadEventAsync(); + Assert.NotNull(evt); + Assert.Equal("chunk", evt!.Type); + Assert.Contains("Hello", evt.Data); + } + + [Fact] + public async Task ReadEventAsync_ReadsCompleteEvent() + { + var reply = new OpenCodeReply + { + Parts = new() { new() { Type = "text", Text = "done" } }, + MessageId = "msg_1" + }; + var json = JsonSerializer.Serialize(reply); + var sse = $"event: complete\ndata: {json}\n\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sse)); + using var reader = new SseReader(stream); + var evt = await reader.ReadEventAsync(); + Assert.NotNull(evt); + Assert.Equal("complete", evt!.Type); + var deserialized = JsonSerializer.Deserialize(evt.Data); + Assert.NotNull(deserialized); + Assert.Equal("done", deserialized!.GetText()); + } + + [Fact] + public async Task ReadEventAsync_ReadsErrorEvent() + { + var sse = "event: error\ndata: something went wrong\n\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sse)); + using var reader = new SseReader(stream); + var evt = await reader.ReadEventAsync(); + Assert.NotNull(evt); + Assert.Equal("error", evt!.Type); + Assert.Equal("something went wrong", evt.Data); + } + + [Fact] + public async Task ReadEventAsync_SkipsCommentLines() + { + var sse = ":comment\nevent: chunk\ndata: {\"type\":\"text\",\"text\":\"skip\"}\n\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sse)); + using var reader = new SseReader(stream); + var evt = await reader.ReadEventAsync(); + Assert.NotNull(evt); + Assert.Equal("chunk", evt!.Type); + } + + [Fact] + public async Task ReadEventAsync_DefaultsToMessageType() + { + var sse = "data: {\"type\":\"text\",\"text\":\"hi\"}\n\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sse)); + using var reader = new SseReader(stream); + var evt = await reader.ReadEventAsync(); + Assert.NotNull(evt); + Assert.Equal("message", evt!.Type); + } + + [Fact] + public async Task ReadEventAsync_HandlesMultipleEvents() + { + var sse = "event: chunk\ndata: {\"type\":\"text\",\"text\":\"first\"}\n\n" + + "event: chunk\ndata: {\"type\":\"text\",\"text\":\"second\"}\n\n" + + "event: complete\ndata: {}\n\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sse)); + using var reader = new SseReader(stream); + var e1 = await reader.ReadEventAsync(); + Assert.Equal("chunk", e1!.Type); + Assert.Contains("first", e1.Data); + var e2 = await reader.ReadEventAsync(); + Assert.Equal("chunk", e2!.Type); + Assert.Contains("second", e2.Data); + var e3 = await reader.ReadEventAsync(); + Assert.Equal("complete", e3!.Type); + Assert.Equal("{}", e3.Data); + var e4 = await reader.ReadEventAsync(); + Assert.Null(e4); + } +} From cb0619f786f398cf8dc112080dfb71fed3e79ba4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:19:33 +0000 Subject: [PATCH 2/2] Bump Microsoft.Bcl.AsyncInterfaces and System.Text.Json Bumps Microsoft.Bcl.AsyncInterfaces from 10.0.8 to 10.0.9 Bumps System.Text.Json from 10.0.8 to 10.0.9 --- updated-dependencies: - dependency-name: Microsoft.Bcl.AsyncInterfaces dependency-version: 10.0.9 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: System.Text.Json dependency-version: 10.0.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- src/NOpenCode/NOpenCode.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NOpenCode/NOpenCode.csproj b/src/NOpenCode/NOpenCode.csproj index 2c52538..feebdef 100644 --- a/src/NOpenCode/NOpenCode.csproj +++ b/src/NOpenCode/NOpenCode.csproj @@ -21,9 +21,9 @@ - + - +