Merge nucleic/warm-north-vole-pf37 into dev
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using NucleicApp.Services;
|
||||||
|
using NucleicApp.ViewModels;
|
||||||
|
using NucleicProtocol.Interop;
|
||||||
|
|
||||||
|
namespace NucleicApp.Tests;
|
||||||
|
|
||||||
|
public sealed class PresentationModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HomeDerivesAttentionActivityAndCountsOnlyFromHostProjections()
|
||||||
|
{
|
||||||
|
using var store = new RendererStore();
|
||||||
|
store.Apply("""
|
||||||
|
{"input":[
|
||||||
|
{"archived":false,"disposition":"awaitingInput","pendingApprovalCount":0,
|
||||||
|
"sessionID":"needs-input","status":"awaitingInput","title":"Needs input","updatedAt":3},
|
||||||
|
{"archived":false,"pendingApprovalCount":0,
|
||||||
|
"sessionID":"running","status":"running","title":"Running","updatedAt":2},
|
||||||
|
{"archived":true,"pendingApprovalCount":1,
|
||||||
|
"sessionID":"archived","status":"awaitingApproval","title":"Archived","updatedAt":4}
|
||||||
|
],"t":"sessionList"}
|
||||||
|
""");
|
||||||
|
store.Apply("""
|
||||||
|
{"activity":[],"counts":{"activeChats":2,"chats":3,"messages":8,
|
||||||
|
"projects":1,"tokens":1200},"projects":[],"statusFeeds":[],
|
||||||
|
"t":"dashboard","todos":[{"status":"open"},{"status":"done"}]}
|
||||||
|
""");
|
||||||
|
using var home = new HomeViewModel(
|
||||||
|
store, () => new DateTimeOffset(2026, 8, 4, 9, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
Assert.Equal("Good morning", home.Greeting);
|
||||||
|
Assert.Equal(new DashboardCounts(1, 2, 3, 8, 1200), home.Counts);
|
||||||
|
Assert.Equal("2 active of 3 chats · 1 project", home.Subtitle);
|
||||||
|
Assert.Equal("needs-input", Assert.Single(home.NeedsAttention).Id);
|
||||||
|
Assert.Equal("running", Assert.Single(home.InProgress).Id);
|
||||||
|
Assert.Equal(2, home.LiveSessions.Count);
|
||||||
|
Assert.Equal(1, home.OpenTodoCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShellSessionLifecycleEmitsSubscriptionComposerAndApprovalIntents()
|
||||||
|
{
|
||||||
|
var directory = Directory.CreateTempSubdirectory("nucleic-shell-");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rendezvousPath = Path.Combine(directory.FullName, "hostd.json");
|
||||||
|
await File.WriteAllTextAsync(rendezvousPath, JsonSerializer.Serialize(
|
||||||
|
ProtocolInteropTests.ValidRendezvous()));
|
||||||
|
var native = new FakeNativeProtocol
|
||||||
|
{
|
||||||
|
ProjectionResult =
|
||||||
|
"{\"sessionID\":\"session-1\",\"splices\":[{\"deleteCount\":0,\"items\":[],\"start\":0}],\"version\":1}",
|
||||||
|
};
|
||||||
|
using var store = new RendererStore(new ProtocolProjection(native));
|
||||||
|
store.Apply(TestRepository.Fixture("host", "sessionList"));
|
||||||
|
store.Apply(TestRepository.Fixture("host", "approvalRequested"));
|
||||||
|
|
||||||
|
var client = new FakeProtocolClient();
|
||||||
|
await using var connection = new HostConnection(
|
||||||
|
store, Path.Combine(directory.FullName, "identity"),
|
||||||
|
new FakeProtocolClientFactory(client), InlineRendererDispatcher.Instance);
|
||||||
|
await connection.StartLocalAsync(rendezvousPath);
|
||||||
|
using var shell = new ShellViewModel(store, connection);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolResult.Ok, shell.OpenSession("session-1", 6));
|
||||||
|
var detail = Assert.IsType<SessionDetailViewModel>(shell.SelectedSession);
|
||||||
|
Assert.Equal(ShellSection.Sessions, shell.Section);
|
||||||
|
Assert.Equal("session-1", detail.Session?.Id);
|
||||||
|
Assert.Equal("approval-1", Assert.Single(detail.PendingApprovals).Id);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolResult.InvalidArgument, detail.SendText(" "));
|
||||||
|
Assert.Equal(ProtocolResult.Ok, detail.SendText("continue"));
|
||||||
|
var approval = Assert.Single(detail.PendingApprovals);
|
||||||
|
Assert.Equal(ProtocolResult.Ok, detail.Allow(approval));
|
||||||
|
Assert.Equal(ProtocolResult.Ok, detail.Deny(approval, "not now"));
|
||||||
|
Assert.Equal(ProtocolResult.Ok,
|
||||||
|
detail.AllowAlways(approval, ApprovalAlwaysScope.ToolName));
|
||||||
|
Assert.Equal(ProtocolResult.Ok, detail.CancelRun(approval));
|
||||||
|
|
||||||
|
store.Apply(TestRepository.Fixture("host", "pong"));
|
||||||
|
Assert.NotNull(detail.LastProjection);
|
||||||
|
|
||||||
|
shell.Navigate(ShellSection.Home);
|
||||||
|
Assert.Null(shell.SelectedSession);
|
||||||
|
Assert.Equal(ShellSection.Home, shell.Section);
|
||||||
|
|
||||||
|
var messages = client.Intents.Select(ProtocolMessage.Parse).ToArray();
|
||||||
|
Assert.Equal(
|
||||||
|
["subscribe", "sendInput", "approvalRespond", "approvalRespond",
|
||||||
|
"approvalRespond", "approvalRespond", "unsubscribe"],
|
||||||
|
messages.Select(message => message.Tag));
|
||||||
|
Assert.Equal(6UL, messages[0].Root.GetProperty("sinceSeq").GetUInt64());
|
||||||
|
Assert.Equal("continue",
|
||||||
|
messages[1].Root.GetProperty("input").GetProperty("parts")[0]
|
||||||
|
.GetProperty("text").GetString());
|
||||||
|
Assert.Equal("allow",
|
||||||
|
messages[2].Root.GetProperty("decision").GetProperty("type").GetString());
|
||||||
|
Assert.Equal("deny",
|
||||||
|
messages[3].Root.GetProperty("decision").GetProperty("type").GetString());
|
||||||
|
Assert.Equal("not now",
|
||||||
|
messages[3].Root.GetProperty("decision").GetProperty("reason").GetString());
|
||||||
|
Assert.Equal("allowAlways",
|
||||||
|
messages[4].Root.GetProperty("decision").GetProperty("type").GetString());
|
||||||
|
Assert.Equal("toolName",
|
||||||
|
messages[4].Root.GetProperty("decision").GetProperty("scope").GetString());
|
||||||
|
Assert.Equal("cancelRun",
|
||||||
|
messages[5].Root.GetProperty("decision").GetProperty("type").GetString());
|
||||||
|
Assert.Equal("session-1", messages[6].Root.GetProperty("sessionID").GetString());
|
||||||
|
}
|
||||||
|
finally { directory.Delete(recursive: true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using NucleicApp.Services;
|
||||||
|
|
||||||
|
namespace NucleicApp.ViewModels;
|
||||||
|
|
||||||
|
/// Presentation state for the first WinUI Home screen. It derives every value from
|
||||||
|
/// ``RendererStore`` projections; it never mutates host-owned state.
|
||||||
|
public sealed class HomeViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly RendererStore store;
|
||||||
|
private readonly Func<DateTimeOffset> now;
|
||||||
|
|
||||||
|
public HomeViewModel(RendererStore store, Func<DateTimeOffset>? now = null)
|
||||||
|
{
|
||||||
|
this.store = store;
|
||||||
|
this.now = now ?? (() => DateTimeOffset.Now);
|
||||||
|
store.PropertyChanged += StoreChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Greeting => now().Hour switch
|
||||||
|
{
|
||||||
|
>= 5 and < 12 => "Good morning",
|
||||||
|
>= 12 and < 17 => "Good afternoon",
|
||||||
|
>= 17 and < 22 => "Good evening",
|
||||||
|
_ => "Hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
public DashboardCounts Counts
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (store.Dashboard is not { } dashboard ||
|
||||||
|
!dashboard.TryGetProperty("counts", out var counts)) return new();
|
||||||
|
return new DashboardCounts(
|
||||||
|
Int(counts, "projects"), Int(counts, "activeChats"),
|
||||||
|
Int(counts, "chats"), Int(counts, "messages"), Long(counts, "tokens"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Subtitle => Counts.Chats == 0
|
||||||
|
? "Start a chat below to get going."
|
||||||
|
: $"{Counts.ActiveChats} active of {Counts.Chats} chats · {Counts.Projects} "
|
||||||
|
+ (Counts.Projects == 1 ? "project" : "projects");
|
||||||
|
|
||||||
|
public IReadOnlyList<SessionSummary> LiveSessions => store.Sessions
|
||||||
|
.Where(session => !Bool(session.Raw, "archived"))
|
||||||
|
.OrderByDescending(UpdatedAt)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public IReadOnlyList<SessionSummary> NeedsAttention => LiveSessions
|
||||||
|
.Where(session => session.PendingApprovalCount > 0 || session.Status == "awaitingApproval" ||
|
||||||
|
(session.Status == "awaitingInput" && String(session.Raw, "disposition") == "awaitingInput"))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public IReadOnlyList<SessionSummary> InProgress => LiveSessions
|
||||||
|
.Where(session => session.Status is "provisioning" or "running")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public int OpenTodoCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (store.Dashboard is not { } dashboard ||
|
||||||
|
!dashboard.TryGetProperty("todos", out var todos) ||
|
||||||
|
todos.ValueKind != JsonValueKind.Array) return 0;
|
||||||
|
return todos.EnumerateArray().Count(todo => String(todo, "status") != "done");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StoreChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.PropertyName == nameof(RendererStore.Sessions))
|
||||||
|
{
|
||||||
|
Raise(nameof(LiveSessions));
|
||||||
|
Raise(nameof(NeedsAttention));
|
||||||
|
Raise(nameof(InProgress));
|
||||||
|
}
|
||||||
|
else if (args.PropertyName == nameof(RendererStore.Dashboard))
|
||||||
|
{
|
||||||
|
Raise(nameof(Counts));
|
||||||
|
Raise(nameof(Subtitle));
|
||||||
|
Raise(nameof(OpenTodoCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Bool(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True;
|
||||||
|
private static string? String(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||||
|
? value.GetString() : null;
|
||||||
|
private static int Int(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : 0;
|
||||||
|
private static long Long(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var value) && value.TryGetInt64(out var result) ? result : 0;
|
||||||
|
private static double UpdatedAt(SessionSummary session) =>
|
||||||
|
session.Raw.TryGetProperty("updatedAt", out var value) && value.TryGetDouble(out var result)
|
||||||
|
? result : 0;
|
||||||
|
|
||||||
|
public void Dispose() => store.PropertyChanged -= StoreChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DashboardCounts(
|
||||||
|
int Projects = 0, int ActiveChats = 0, int Chats = 0,
|
||||||
|
int Messages = 0, long Tokens = 0);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using NucleicApp.Services;
|
||||||
|
using NucleicProtocol.Interop;
|
||||||
|
|
||||||
|
namespace NucleicApp.ViewModels;
|
||||||
|
|
||||||
|
/// Presentation and intent surface for one session detail screen. Transcript rows remain owned
|
||||||
|
/// by the shared Swift projector; this model forwards only that session's splice diffs.
|
||||||
|
public sealed class SessionDetailViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly RendererStore store;
|
||||||
|
private readonly HostConnection connection;
|
||||||
|
private ProjectionDiff? lastProjection;
|
||||||
|
private bool active;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public SessionDetailViewModel(
|
||||||
|
string sessionId, RendererStore store, HostConnection connection)
|
||||||
|
{
|
||||||
|
SessionId = sessionId;
|
||||||
|
this.store = store;
|
||||||
|
this.connection = connection;
|
||||||
|
store.PropertyChanged += StoreChanged;
|
||||||
|
store.ProjectionChanged += ProjectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId { get; }
|
||||||
|
public SessionSummary? Session => store.Sessions.FirstOrDefault(item => item.Id == SessionId);
|
||||||
|
public IReadOnlyList<ApprovalRequest> PendingApprovals => store.PendingApprovals
|
||||||
|
.Where(item => item.SessionId == SessionId).ToArray();
|
||||||
|
public ProjectionDiff? LastProjection
|
||||||
|
{
|
||||||
|
get => lastProjection;
|
||||||
|
private set => Set(ref lastProjection, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProtocolResult Activate(ulong? sinceSequence = null)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (active) return ProtocolResult.Ok;
|
||||||
|
var result = connection.SendIntent(ClientIntents.Subscribe(SessionId, sinceSequence));
|
||||||
|
if (result == ProtocolResult.Ok) active = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProtocolResult SendText(string text)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return ProtocolResult.InvalidArgument;
|
||||||
|
return connection.SendIntent(ClientIntents.SendInput(SessionId, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProtocolResult Allow(ApprovalRequest approval, JsonElement? updatedInput = null) =>
|
||||||
|
Respond(approval, ClientIntents.ApprovalAllow(approval.Id, updatedInput));
|
||||||
|
|
||||||
|
public ProtocolResult Deny(ApprovalRequest approval, string? reason = null) =>
|
||||||
|
Respond(approval, ClientIntents.ApprovalDeny(approval.Id, reason));
|
||||||
|
|
||||||
|
public ProtocolResult AllowAlways(ApprovalRequest approval, ApprovalAlwaysScope scope) =>
|
||||||
|
Respond(approval, ClientIntents.ApprovalAllowAlways(approval.Id, scope));
|
||||||
|
|
||||||
|
public ProtocolResult CancelRun(ApprovalRequest approval) =>
|
||||||
|
Respond(approval, ClientIntents.ApprovalCancelRun(approval.Id));
|
||||||
|
|
||||||
|
private ProtocolResult Respond(ApprovalRequest approval, string intent)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (approval.SessionId != SessionId) return ProtocolResult.InvalidArgument;
|
||||||
|
return connection.SendIntent(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StoreChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.PropertyName == nameof(RendererStore.Sessions)) Raise(nameof(Session));
|
||||||
|
else if (args.PropertyName == nameof(RendererStore.PendingApprovals))
|
||||||
|
Raise(nameof(PendingApprovals));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProjectionChanged(ProjectionDiff diff)
|
||||||
|
{
|
||||||
|
if (diff.Root.TryGetProperty("sessionID", out var session) &&
|
||||||
|
session.ValueKind == JsonValueKind.String && session.GetString() == SessionId)
|
||||||
|
{
|
||||||
|
LastProjection = diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
store.PropertyChanged -= StoreChanged;
|
||||||
|
store.ProjectionChanged -= ProjectionChanged;
|
||||||
|
if (active) _ = connection.SendIntent(ClientIntents.Unsubscribe(SessionId));
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using NucleicApp.Services;
|
||||||
|
using NucleicProtocol.Interop;
|
||||||
|
|
||||||
|
namespace NucleicApp.ViewModels;
|
||||||
|
|
||||||
|
public enum ShellSection
|
||||||
|
{
|
||||||
|
Home,
|
||||||
|
Sessions,
|
||||||
|
Approvals,
|
||||||
|
Projects,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation state for the WinUI split shell. The future XAML layer binds to this class and
|
||||||
|
/// swaps views; session activation remains an ordinary protocol subscription intent.
|
||||||
|
public sealed class ShellViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly RendererStore store;
|
||||||
|
private readonly HostConnection connection;
|
||||||
|
private ShellSection section = ShellSection.Home;
|
||||||
|
private SessionDetailViewModel? selectedSession;
|
||||||
|
private HostConnectionState connectionState;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public ShellViewModel(RendererStore store, HostConnection connection)
|
||||||
|
{
|
||||||
|
this.store = store;
|
||||||
|
this.connection = connection;
|
||||||
|
connectionState = connection.State;
|
||||||
|
store.PropertyChanged += StoreChanged;
|
||||||
|
connection.StateChanged += ConnectionChanged;
|
||||||
|
Home = new HomeViewModel(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HomeViewModel Home { get; }
|
||||||
|
public ShellSection Section { get => section; private set => Set(ref section, value); }
|
||||||
|
public SessionDetailViewModel? SelectedSession
|
||||||
|
{
|
||||||
|
get => selectedSession;
|
||||||
|
private set => Set(ref selectedSession, value);
|
||||||
|
}
|
||||||
|
public HostConnectionState ConnectionState
|
||||||
|
{
|
||||||
|
get => connectionState;
|
||||||
|
private set => Set(ref connectionState, value);
|
||||||
|
}
|
||||||
|
public int PendingApprovalCount => store.PendingApprovals.Count;
|
||||||
|
|
||||||
|
public void Navigate(ShellSection destination)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (destination != ShellSection.Sessions) CloseSession();
|
||||||
|
Section = destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProtocolResult OpenSession(string sessionId, ulong? sinceSequence = null)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (SelectedSession?.SessionId != sessionId)
|
||||||
|
{
|
||||||
|
SelectedSession?.Dispose();
|
||||||
|
SelectedSession = new SessionDetailViewModel(sessionId, store, connection);
|
||||||
|
}
|
||||||
|
Section = ShellSection.Sessions;
|
||||||
|
return SelectedSession.Activate(sinceSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CloseSession()
|
||||||
|
{
|
||||||
|
SelectedSession?.Dispose();
|
||||||
|
SelectedSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StoreChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.PropertyName == nameof(RendererStore.PendingApprovals))
|
||||||
|
Raise(nameof(PendingApprovalCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConnectionChanged(HostConnectionState state) => ConnectionState = state;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
CloseSession();
|
||||||
|
Home.Dispose();
|
||||||
|
store.PropertyChanged -= StoreChanged;
|
||||||
|
connection.StateChanged -= ConnectionChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace NucleicApp.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||||
|
field = value;
|
||||||
|
Raise(name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void Raise([CallerMemberName] string? name = null) =>
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
@@ -27,22 +27,17 @@ public static class ClientIntents
|
|||||||
writer.WriteString("t", "unsubscribe");
|
writer.WriteString("t", "unsubscribe");
|
||||||
});
|
});
|
||||||
|
|
||||||
public static string ApprovalRespond(
|
public static string ApprovalAllow(string approvalId, JsonElement? updatedInput = null) =>
|
||||||
string approvalId, string decisionType, JsonElement? updatedInput = null) =>
|
ApprovalRespond(approvalId, "allow", updatedInput: updatedInput);
|
||||||
Write(writer =>
|
|
||||||
{
|
public static string ApprovalDeny(string approvalId, string? reason = null) =>
|
||||||
writer.WriteString("approvalID", approvalId);
|
ApprovalRespond(approvalId, "deny", reason: reason);
|
||||||
writer.WritePropertyName("decision");
|
|
||||||
writer.WriteStartObject();
|
public static string ApprovalAllowAlways(string approvalId, ApprovalAlwaysScope scope) =>
|
||||||
writer.WriteString("type", decisionType);
|
ApprovalRespond(approvalId, "allowAlways", scope: ScopeValue(scope));
|
||||||
if (updatedInput is { } input)
|
|
||||||
{
|
public static string ApprovalCancelRun(string approvalId) =>
|
||||||
writer.WritePropertyName("updatedInput");
|
ApprovalRespond(approvalId, "cancelRun");
|
||||||
input.WriteTo(writer);
|
|
||||||
}
|
|
||||||
writer.WriteEndObject();
|
|
||||||
writer.WriteString("t", "approvalRespond");
|
|
||||||
});
|
|
||||||
|
|
||||||
public static string SendInput(string sessionId, string text) => Write(writer =>
|
public static string SendInput(string sessionId, string text) => Write(writer =>
|
||||||
{
|
{
|
||||||
@@ -63,6 +58,33 @@ public static class ClientIntents
|
|||||||
private static string TagOnly(string tag) => JsonSerializer.Serialize(
|
private static string TagOnly(string tag) => JsonSerializer.Serialize(
|
||||||
new TagOnlyIntent(tag), ProtocolJsonContext.Default.TagOnlyIntent);
|
new TagOnlyIntent(tag), ProtocolJsonContext.Default.TagOnlyIntent);
|
||||||
|
|
||||||
|
private static string ApprovalRespond(
|
||||||
|
string approvalId, string decisionType, JsonElement? updatedInput = null,
|
||||||
|
string? scope = null, string? reason = null) => Write(writer =>
|
||||||
|
{
|
||||||
|
writer.WriteString("approvalID", approvalId);
|
||||||
|
writer.WritePropertyName("decision");
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteString("type", decisionType);
|
||||||
|
if (updatedInput is { } input)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName("updatedInput");
|
||||||
|
input.WriteTo(writer);
|
||||||
|
}
|
||||||
|
if (scope is not null) writer.WriteString("scope", scope);
|
||||||
|
if (reason is not null) writer.WriteString("reason", reason);
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.WriteString("t", "approvalRespond");
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string ScopeValue(ApprovalAlwaysScope scope) => scope switch
|
||||||
|
{
|
||||||
|
ApprovalAlwaysScope.Session => "session",
|
||||||
|
ApprovalAlwaysScope.ToolName => "toolName",
|
||||||
|
ApprovalAlwaysScope.ToolNameWithPattern => "toolNameWithPattern",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(scope)),
|
||||||
|
};
|
||||||
|
|
||||||
private static string Write(Action<Utf8JsonWriter> body)
|
private static string Write(Action<Utf8JsonWriter> body)
|
||||||
{
|
{
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
@@ -78,3 +100,10 @@ public static class ClientIntents
|
|||||||
return System.Text.Encoding.UTF8.GetString(stream.GetBuffer(), 0, checked((int)stream.Length));
|
return System.Text.Encoding.UTF8.GetString(stream.GetBuffer(), 0, checked((int)stream.Length));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ApprovalAlwaysScope
|
||||||
|
{
|
||||||
|
Session,
|
||||||
|
ToolName,
|
||||||
|
ToolNameWithPattern,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user