From 9b53b68b5833ad25e7ce37396cc47cb4f49da815 Mon Sep 17 00:00:00 2001 From: ncosentino Date: Fri, 31 Jul 2026 18:08:13 -0700 Subject: [PATCH] fix: preserve framework-owned eve input request kinds eve stamps every human-input request with a framework-owned kind of question, tool-approval, or session-limit so consumers route by the discriminator instead of shape-sniffing option lists, display hints, or tool names. The typed projection dropped that value, so a session-limit gate offering continue/stop was indistinguishable from an approve/deny tool prompt. EveInputRequest now exposes a strongly typed Kind alongside RawKind, which preserves the wire value. An unmodelled future kind reports Unknown with the raw value intact; a server that predates the discriminator reports Unknown with a null raw value. A present non-string kind fails as a protocol error rather than impersonating a legacy server. The pinned eve 0.27.6 fixture gains an approval-gated tool so the compatibility probe drives a real input.requested pause, asserts the legacy projection, and answers the approval to resume the turn. Closes #25 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6f397bc8-d45e-4945-a1f7-eb8d8d91a4fc --- docs/attachments-and-input.md | 31 ++++ docs/compatibility.md | 12 ++ .../EveInputRequestKindTests.cs | 166 ++++++++++++++++++ src/NexusLabs.Eve/EveInputRequest.cs | 24 +++ src/NexusLabs.Eve/EveInputRequestKind.cs | 41 +++++ src/NexusLabs.Eve/EveMessageResponse.cs | 30 ++++ test/fixtures/eve-agent/agent/agent.ts | 36 +++- .../eve-agent/agent/tools/request_approval.ts | 16 ++ .../Program.cs | 44 +++++ 9 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 src/NexusLabs.Eve.Tests/EveInputRequestKindTests.cs create mode 100644 src/NexusLabs.Eve/EveInputRequestKind.cs create mode 100644 test/fixtures/eve-agent/agent/tools/request_approval.ts diff --git a/docs/attachments-and-input.md b/docs/attachments-and-input.md index 46a1309..3a7b785 100644 --- a/docs/attachments-and-input.md +++ b/docs/attachments-and-input.md @@ -40,3 +40,34 @@ EveMessageResponse resumed = await session.SendAsync( Input responses are retried for the short propagation window where an accepted durable session is not yet visible to the delivery route. + +## Classify a request + +eve stamps every input request with a framework-owned discriminator, projected as +`EveInputRequest.Kind`. Route on it instead of inferring intent from `Display`, +`Options`, or the tool name inside `Action`: a session-limit prompt can arrive +with a confirmation hint, two options, and a tool name, yet it is not an +approve/deny tool prompt. + +```csharp +string answer = request.Kind switch +{ + EveInputRequestKind.ToolApproval => "approve", + EveInputRequestKind.SessionLimit => "continue", + EveInputRequestKind.Question => ChooseAnswer(request), + _ => throw new NotSupportedException( + $"Unhandled eve input request kind '{request.RawKind}'."), +}; +``` + +`Kind` reports `EveInputRequestKind.Unknown` in two cases, and `RawKind` +distinguishes them: + +| `Kind` | `RawKind` | Meaning | +|---|---|---| +| `Question`, `ToolApproval`, `SessionLimit` | matching wire value | A modelled request kind | +| `Unknown` | the wire value | A newer eve emitted a kind this package does not model | +| `Unknown` | `null` | The server predates the discriminator, such as eve `0.27.6` | + +A `kind` that is present but not a string is a malformed request and throws +`EveProtocolException` rather than being reported as a legacy server. diff --git a/docs/compatibility.md b/docs/compatibility.md index ba4db2b..6c22b0d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -55,3 +55,15 @@ header, so bounded reads against that baseline fail with `EveProtocolException` instead of silently degrading to a live follow. The compatibility probe asserts both halves of that contract and switches to verifying a real bounded read once the pinned server reports the header. + +## Input request kinds + +eve stamps each human-input request with a framework-owned `kind` of `question`, +`tool-approval`, or `session-limit`. `EveInputRequest.Kind` projects it and +`EveInputRequest.RawKind` preserves the wire value, so an unmodelled future kind +stays inspectable instead of being misclassified from its option shape. + +eve `0.27.6` predates the discriminator and omits it, which reports +`EveInputRequestKind.Unknown` with a `null` raw value. The compatibility probe +drives a real approval-gated tool against the pinned fixture and asserts that +behavior end to end. diff --git a/src/NexusLabs.Eve.Tests/EveInputRequestKindTests.cs b/src/NexusLabs.Eve.Tests/EveInputRequestKindTests.cs new file mode 100644 index 0000000..4e653b1 --- /dev/null +++ b/src/NexusLabs.Eve.Tests/EveInputRequestKindTests.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Text; + +namespace NexusLabs.Eve.Tests; + +public sealed class EveInputRequestKindTests +{ + [Test] + [Arguments("question", EveInputRequestKind.Question)] + [Arguments("tool-approval", EveInputRequestKind.ToolApproval)] + [Arguments("session-limit", EveInputRequestKind.SessionLimit)] + public async Task InputRequest_ProjectsFrameworkOwnedKind( + string wireKind, + EveInputRequestKind expectedKind, + CancellationToken cancellationToken) + { + EveTurnOutcome outcome = await CollectOutcomeAsync( + InputRequestedEvent($",\"kind\":\"{wireKind}\""), + cancellationToken); + + await Assert.That(outcome.InputRequests.Count).IsEqualTo(1); + await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(expectedKind); + await Assert.That(outcome.InputRequests[0].RawKind).IsEqualTo(wireKind); + } + + [Test] + public async Task SessionLimitRequest_IsNotClassifiedAsToolApprovalByItsShape( + CancellationToken cancellationToken) + { + EveTurnOutcome outcome = await CollectOutcomeAsync( + """{"type":"input.requested","data":{"requests":[{"requestId":"limit_1","prompt":"The session reached its step limit.","kind":"session-limit","display":"confirmation","options":[{"id":"continue","label":"Continue"},{"id":"stop","label":"Stop"}],"action":{"kind":"tool-call","toolName":"bash"}}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""", + cancellationToken); + + await Assert.That(outcome.InputRequests.Count).IsEqualTo(1); + EveInputRequest request = outcome.InputRequests[0]; + await Assert.That(request.Kind).IsEqualTo(EveInputRequestKind.SessionLimit); + await Assert.That(request.Display) + .IsEqualTo("confirmation") + .Because("A confirmation display hint no longer implies a tool approval."); + await Assert.That(request.Options.Count).IsEqualTo(2); + await Assert.That(request.Options[0].Id).IsEqualTo("continue"); + await Assert.That(request.Options[1].Id).IsEqualTo("stop"); + await Assert.That(request.Action.GetProperty("toolName").GetString()) + .IsEqualTo("bash") + .Because("An accompanying tool name no longer implies a tool approval."); + } + + [Test] + public async Task QuestionRequest_IsNotClassifiedByItsTwoOptionShape( + CancellationToken cancellationToken) + { + EveTurnOutcome outcome = await CollectOutcomeAsync( + """{"type":"input.requested","data":{"requests":[{"requestId":"question_1","prompt":"Ship it?","kind":"question","display":"confirmation","allowFreeform":true,"options":[{"id":"approve","label":"Yes"},{"id":"deny","label":"No"}],"action":{"kind":"tool-call"}}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""", + cancellationToken); + + await Assert.That(outcome.InputRequests.Count).IsEqualTo(1); + EveInputRequest request = outcome.InputRequests[0]; + await Assert.That(request.Kind) + .IsEqualTo(EveInputRequestKind.Question) + .Because("Approve/deny options with a confirmation hint are not always an approval."); + await Assert.That(request.RawKind).IsEqualTo("question"); + await Assert.That(request.AllowFreeform.GetValueOrDefault()) + .IsTrue() + .Because("The stream reported allowFreeform."); + await Assert.That(request.Options.Count).IsEqualTo(2); + } + + [Test] + public async Task UnrecognizedKind_StaysInspectableWithoutMisclassification( + CancellationToken cancellationToken) + { + EveTurnOutcome outcome = await CollectOutcomeAsync( + InputRequestedEvent(",\"kind\":\"escalation\""), + cancellationToken); + + await Assert.That(outcome.InputRequests.Count).IsEqualTo(1); + await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(EveInputRequestKind.Unknown); + await Assert.That(outcome.InputRequests[0].RawKind) + .IsEqualTo("escalation") + .Because("A future discriminator must remain readable on the wire value."); + } + + [Test] + public async Task AbsentKind_IsReportedAsALegacyServerRatherThanAnUnknownValue( + CancellationToken cancellationToken) + { + EveTurnOutcome outcome = await CollectOutcomeAsync( + InputRequestedEvent(string.Empty), + cancellationToken); + + await Assert.That(outcome.InputRequests.Count).IsEqualTo(1); + await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(EveInputRequestKind.Unknown); + await Assert.That(outcome.InputRequests[0].RawKind) + .IsNull() + .Because("eve versions before the discriminator omit it entirely."); + await Assert.That(outcome.InputRequests[0].Action.GetProperty("kind").GetString()) + .IsEqualTo("tool-call") + .Because("The action's own kind must not be read as the request discriminator."); + await Assert.That(outcome.InputRequests[0].RequestId).IsEqualTo("request_1"); + } + + [Test] + public async Task NonStringKind_FailsInsteadOfImpersonatingALegacyServer( + CancellationToken cancellationToken) + { + await Assert.That(async () => await CollectOutcomeAsync( + InputRequestedEvent(",\"kind\":7"), + cancellationToken)) + .Throws(); + } + + [Test] + public async Task MultipleRequests_KeepTheirOwnKinds(CancellationToken cancellationToken) + { + EveTurnOutcome outcome = await CollectOutcomeAsync( + """{"type":"input.requested","data":{"requests":[{"requestId":"approval_1","prompt":"Run bash?","kind":"tool-approval","display":"confirmation","options":[{"id":"approve","label":"Yes"}],"action":{"kind":"tool-call","toolName":"bash"}},{"requestId":"question_1","prompt":"Which environment?","kind":"question","display":"select","options":[{"id":"prod","label":"Production"}],"action":{"kind":"tool-call"}}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}""", + cancellationToken); + + await Assert.That(outcome.InputRequests.Count).IsEqualTo(2); + await Assert.That(outcome.InputRequests[0].Kind).IsEqualTo(EveInputRequestKind.ToolApproval); + await Assert.That(outcome.InputRequests[0].RequestId).IsEqualTo("approval_1"); + await Assert.That(outcome.InputRequests[1].Kind).IsEqualTo(EveInputRequestKind.Question); + await Assert.That(outcome.InputRequests[1].RequestId).IsEqualTo("question_1"); + } + + private static string InputRequestedEvent(string kindProperty) => + """{"type":"input.requested","data":{"requests":[{"requestId":"request_1","prompt":"Continue?","display":"confirmation","options":[{"id":"approve","label":"Approve"}],"action":{"kind":"tool-call"}""" + + kindProperty + + """}],"sequence":1,"stepIndex":0,"turnId":"turn_1"}}"""; + + private static async Task CollectOutcomeAsync( + string inputRequestedEvent, + CancellationToken cancellationToken) + { + using RecordingHttpMessageHandler handler = new(); + using HttpMessageInvoker transport = new(handler, false); + handler.Enqueue(static (_, _) => Task.FromResult(AcceptedResponse())); + handler.Enqueue((_, _) => Task.FromResult(StreamResponse( + inputRequestedEvent, + """{"type":"session.waiting","data":{"continuationToken":"eve:next","wait":"human-input"}}"""))); + EveSession session = new EveClient( + transport, + new EveClientOptions("https://agent.example.com")).CreateSession(); + + EveMessageResponse response = await session.SendAsync("Continue", cancellationToken); + return await response.GetOutcomeAsync(cancellationToken); + } + + private static HttpResponseMessage AcceptedResponse() => + new(HttpStatusCode.Accepted) + { + Content = new StringContent( + """{"ok":true,"sessionId":"session_1","continuationToken":"eve:accepted"}""", + Encoding.UTF8, + "application/json"), + }; + + private static HttpResponseMessage StreamResponse(params string[] events) => + new(HttpStatusCode.OK) + { + Content = new StringContent( + $"{string.Join('\n', events)}\n", + Encoding.UTF8, + EveProtocol.MessageStreamContentType), + }; +} diff --git a/src/NexusLabs.Eve/EveInputRequest.cs b/src/NexusLabs.Eve/EveInputRequest.cs index a48b622..3e4311a 100644 --- a/src/NexusLabs.Eve/EveInputRequest.cs +++ b/src/NexusLabs.Eve/EveInputRequest.cs @@ -10,6 +10,8 @@ public sealed record EveInputRequest internal EveInputRequest( string requestId, string prompt, + EveInputRequestKind kind, + string? rawKind, string? display, bool? allowFreeform, IReadOnlyList options, @@ -17,6 +19,8 @@ internal EveInputRequest( { RequestId = requestId; Prompt = prompt; + Kind = kind; + RawKind = rawKind; Display = display; AllowFreeform = allowFreeform; Options = options; @@ -33,6 +37,26 @@ internal EveInputRequest( /// public string Prompt { get; } + /// + /// Gets the framework-owned request source used to route, render, and answer this request. + /// + /// + /// Prefer this discriminator over inferring intent from , + /// , or the tool name in . It reports + /// when the server sends no discriminator or one this + /// package does not model; then carries the wire value. + /// + public EveInputRequestKind Kind { get; } + + /// + /// Gets the discriminator exactly as the server sent it, or when it sent none. + /// + /// + /// eve versions before the discriminator was introduced omit it entirely, so a + /// value is a legacy server rather than an unrecognized kind. + /// + public string? RawKind { get; } + /// /// Gets the optional rendering hint, such as confirmation, select, or text. /// diff --git a/src/NexusLabs.Eve/EveInputRequestKind.cs b/src/NexusLabs.Eve/EveInputRequestKind.cs new file mode 100644 index 0000000..35097ad --- /dev/null +++ b/src/NexusLabs.Eve/EveInputRequestKind.cs @@ -0,0 +1,41 @@ +namespace NexusLabs.Eve; + +/// +/// Identifies the framework-owned source of an eve human-input request. +/// +/// +/// eve stamps this discriminator on every input request so consumers can route, render, and answer +/// a request without inferring its purpose from option shapes, display hints, or tool names. +/// +public enum EveInputRequestKind +{ + /// + /// The server did not send a recognized discriminator. + /// + /// + /// This covers both an eve version that predates the discriminator and a future value this + /// package does not model. Read to tell them apart: + /// it is when the server sent nothing and carries the wire value + /// otherwise. + /// + Unknown = 0, + + /// + /// The agent asked the user a question. + /// + Question, + + /// + /// The agent needs approval before running a tool. + /// + ToolApproval, + + /// + /// The session reached a configured limit and needs a decision before continuing. + /// + /// + /// Options such as continue and stop belong to this kind. They are not an + /// approve/deny tool prompt even when they arrive with a confirmation display hint. + /// + SessionLimit, +} diff --git a/src/NexusLabs.Eve/EveMessageResponse.cs b/src/NexusLabs.Eve/EveMessageResponse.cs index 758abab..1ecf5eb 100644 --- a/src/NexusLabs.Eve/EveMessageResponse.cs +++ b/src/NexusLabs.Eve/EveMessageResponse.cs @@ -139,6 +139,7 @@ private static void AddInputRequests( JsonElement request = requests[requestIndex]; string requestId = RequireString(request, "requestId"); string prompt = RequireString(request, "prompt"); + string? rawKind = ReadInputRequestKind(request); string? display = OptionalString(request, "display"); bool? allowFreeform = OptionalBoolean(request, "allowFreeform"); JsonElement action = request.TryGetProperty("action", out JsonElement actionValue) @@ -168,6 +169,8 @@ private static void AddInputRequests( inputRequests.Add(new EveInputRequest( requestId, prompt, + ResolveInputRequestKind(rawKind), + rawKind, display, allowFreeform, options, @@ -175,6 +178,33 @@ private static void AddInputRequests( } } + // An absent discriminator is an eve version that predates it; a present non-string value is a + // malformed request that must not be reported as a legacy server. + private static string? ReadInputRequestKind(JsonElement request) + { + if (!request.TryGetProperty("kind", out JsonElement kind)) + { + return null; + } + + if (kind.ValueKind != JsonValueKind.String || kind.GetString() is not string value) + { + throw new EveProtocolException( + "An eve input request kind must be a string."); + } + + return value; + } + + private static EveInputRequestKind ResolveInputRequestKind(string? rawKind) => + rawKind switch + { + "question" => EveInputRequestKind.Question, + "tool-approval" => EveInputRequestKind.ToolApproval, + "session-limit" => EveInputRequestKind.SessionLimit, + _ => EveInputRequestKind.Unknown, + }; + private static string RequireString(JsonElement parent, string propertyName) { if (!parent.TryGetProperty(propertyName, out JsonElement value) diff --git a/test/fixtures/eve-agent/agent/agent.ts b/test/fixtures/eve-agent/agent/agent.ts index 1b8cea8..d767089 100644 --- a/test/fixtures/eve-agent/agent/agent.ts +++ b/test/fixtures/eve-agent/agent/agent.ts @@ -19,14 +19,44 @@ const model = new MockLanguageModelV3({ modelId: "nexuslabs-eve-compatibility", provider: "nexuslabs-test", doStream: async (options) => { - const shouldWaitForCancellation = JSON.stringify(options.prompt).includes( - "WAIT_FOR_CANCEL", - ); + const prompt = JSON.stringify(options.prompt); + const shouldWaitForCancellation = prompt.includes("WAIT_FOR_CANCEL"); + const shouldRequestApproval = + prompt.includes("REQUEST_APPROVAL") && !prompt.includes("APPROVAL_TOOL_OK"); return { stream: new ReadableStream({ start(controller) { controller.enqueue({ type: "stream-start", warnings: [] }); + + if (shouldRequestApproval) { + const input = JSON.stringify({ reason: "compatibility" }); + controller.enqueue({ + id: "call_approval", + toolName: "request_approval", + type: "tool-input-start", + }); + controller.enqueue({ + delta: input, + id: "call_approval", + type: "tool-input-delta", + }); + controller.enqueue({ id: "call_approval", type: "tool-input-end" }); + controller.enqueue({ + input, + toolCallId: "call_approval", + toolName: "request_approval", + type: "tool-call", + }); + controller.enqueue({ + finishReason: { raw: undefined, unified: "tool-calls" }, + type: "finish", + usage, + }); + controller.close(); + return; + } + controller.enqueue({ id: "answer", type: "text-start" }); if (shouldWaitForCancellation) { diff --git a/test/fixtures/eve-agent/agent/tools/request_approval.ts b/test/fixtures/eve-agent/agent/tools/request_approval.ts new file mode 100644 index 0000000..6b64167 --- /dev/null +++ b/test/fixtures/eve-agent/agent/tools/request_approval.ts @@ -0,0 +1,16 @@ +import { defineTool } from "eve/tools"; +import { always } from "eve/tools/approval"; + +export default defineTool({ + approval: always(), + description: "Deterministic approval-gated tool used by the C# compatibility probe.", + inputSchema: { + additionalProperties: false, + properties: { + reason: { type: "string" }, + }, + required: ["reason"], + type: "object", + }, + execute: () => ({ status: "APPROVAL_TOOL_OK" }), +}); diff --git a/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs b/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs index 47aef37..1f72c9b 100644 --- a/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs +++ b/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs @@ -184,6 +184,50 @@ } } +EveSession approvalSession = client.CreateSession(); +EveMessageResponse approvalResponse = await approvalSession.SendAsync( + "REQUEST_APPROVAL", + timeout.Token); +EveTurnOutcome approvalOutcome = await approvalResponse.GetOutcomeAsync(timeout.Token); +if (approvalOutcome.Status != EveTurnStatus.Waiting) +{ + throw new InvalidOperationException( + $"The approval turn did not park for human input: status={approvalOutcome.Status}."); +} + +if (approvalOutcome.InputRequests.Count != 1) +{ + throw new InvalidOperationException( + $"The approval turn emitted {approvalOutcome.InputRequests.Count} input requests."); +} + +EveInputRequest approvalRequest = approvalOutcome.InputRequests[0]; + +// eve 0.27.6, the pinned compatibility baseline, predates the framework-owned discriminator. +// A newer fixture stamps 'tool-approval' here, which flips both assertions. +if (approvalRequest.RawKind is not null + || approvalRequest.Kind != EveInputRequestKind.Unknown) +{ + throw new InvalidOperationException( + $"eve {EveProtocol.ReferenceEveVersion} reported input request kind " + + $"'{approvalRequest.RawKind ?? ""}' projected as {approvalRequest.Kind}."); +} + +if (approvalRequest.Options.Count == 0) +{ + throw new InvalidOperationException( + "The approval request did not offer any selectable options."); +} + +EveMessageResponse resumedResponse = await approvalSession.SendAsync( + new EveSendTurnRequest + { + InputResponses = [new EveInputResponse(approvalRequest.RequestId, "approve")], + }, + timeout.Token); +EveTurnOutcome resumedOutcome = await resumedResponse.GetOutcomeAsync(timeout.Token); +RequireSuccessfulResponse(resumedOutcome, "approved tool turn"); + EveSession resetSession = client.CreateSession(); EveMessageResponse resetResponse = await resetSession.SendAsync( "Return the deterministic compatibility response.",