Skip to content
Closed
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
23 changes: 14 additions & 9 deletions src/NOpenCode/AskOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,18 @@ public AskOperation WithFiles(params string[] files)
return this;
}

public async Task<string> Execute()
public async Task<string> Execute(CancellationToken ct = default)
{
var reply = await ExecuteFull();
var reply = await ExecuteFull(ct);
return reply.GetText();
}

public async Task<OpenCodeReply> ExecuteFull()
public async Task<OpenCodeReply> 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;
}
Expand All @@ -59,18 +61,21 @@ public async Task AskStream(
Action<Exception>? 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<OpenCodeSession> CreateSessionAsync()
private async Task<OpenCodeSession> CreateSessionAsync(CancellationToken ct = default)
{
var title = _prompt.Length > 80
? _prompt.Substring(0, 80) + "..."
: _prompt;

var body = new { title };
var result = await _http.Post<SessionInfo>("/session", body);
var result = await _http.Post<SessionInfo>("/session", body, ct);
return new OpenCodeSession(_http, _config, result.Id);
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/NOpenCode/Clients/ConfigClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,10 @@ public async Task<NOpenCodeConfig> Update(object patch, CancellationToken ct = d
{
return await _http.Patch<NOpenCodeConfig>("/config", patch, ct);
}

public async Task<NOpenCodeConfig> Update(NOpenCodeConfig config, CancellationToken ct = default)
{
return await _http.Patch<NOpenCodeConfig>("/config", config, ct);
}
}
}
10 changes: 10 additions & 0 deletions src/NOpenCode/Clients/McpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,15 @@ public async Task<McpStatus> Add(string name, object config, CancellationToken c
var body = new { name, config };
return await _http.Post<McpStatus>("/mcp", body, ct);
}

public async Task Remove(string name, CancellationToken ct = default)
{
await _http.Delete<bool>($"/mcp/{name}", ct);
}

public async Task<McpStatus> Update(string name, object config, CancellationToken ct = default)
{
return await _http.Patch<McpStatus>($"/mcp/{name}", config, ct);
}
}
}
17 changes: 17 additions & 0 deletions src/NOpenCode/Http/NOpenCodeHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@ public async Task<Stream> GetStream(string path, CancellationToken ct = default)
return await response.Content.ReadAsStreamAsync();
}

public async Task<Stream> 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<string> ReadSse(string path, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
Expand Down
3 changes: 3 additions & 0 deletions src/NOpenCode/Models/Part.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ public class Part

[JsonPropertyName("result")]
public string? Result { get; set; }

[JsonPropertyName("uri")]
public string? Uri { get; set; }
}
}
9 changes: 5 additions & 4 deletions src/NOpenCode/NOpenCode.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PackageId>NOpenCode</PackageId>
<Version>0.2.2</Version>
<Version>0.3.0</Version>
<Authors>ylvict</Authors>
<Description>Empower your .NET applications with OpenCode's AI engine. Express your intent in natural language — NOpenCode bridges your application logic with AI.</Description>
<PackageTags>opencode;ai;sdk;llm;chat;copilot;assistant;dotnet;csharp;natural-language;mcp;sse;integration;automation</PackageTags>
Expand All @@ -20,12 +20,13 @@

<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<InternalsVisibleTo Include="NOpenCode.Tests" />
<PackageReference Include="System.Text.Json" Version="10.0.8" />
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
</ItemGroup>

</Project>
55 changes: 44 additions & 11 deletions src/NOpenCode/OpenCodeSession.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<OpenCodeReply>(
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<Part>(evt.Data);
if (part?.Type == "text" && part.Text != null)
onChunk(part.Text);
}
else if (evt.Type == "complete")
{
finalReply = JsonSerializer.Deserialize<OpenCodeReply>(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);
}
}

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion tests/NOpenCode.Tests/ModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
}

Expand Down
114 changes: 114 additions & 0 deletions tests/NOpenCode.Tests/NewApiTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>), method!.ReturnType);
}

[Fact]
public void AskOperation_ExecuteFull_AcceptsCancellationToken()
{
var method = typeof(AskOperation).GetMethod("ExecuteFull", new[] { typeof(CancellationToken) });
Assert.NotNull(method);
Assert.Equal(typeof(Task<OpenCodeReply>), 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<McpStatus>), 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<string>), 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<MessageOptions>);
});
Assert.NotNull(withConfigure);

var withoutConfigure = methods.FirstOrDefault(m =>
{
var p = m.GetParameters();
return p[0].ParameterType == typeof(string) &&
p[1].ParameterType == typeof(Action<string>);
});
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);
}
}
Loading
Loading