diff --git a/.claude/reference/reuse-before-you-build.md b/.claude/reference/reuse-before-you-build.md new file mode 100644 index 00000000..7b245337 --- /dev/null +++ b/.claude/reference/reuse-before-you-build.md @@ -0,0 +1,44 @@ +# Reuse before you build + +Before you write a new interface, wrapper, or helper, find out if a package +already does the job. + +**Scope** +- Applies when you are about to add an abstraction or a utility. +- Read `Directory.Packages.props` for the packages we already have. +- Search `Microsoft.Extensions.AI*` and `Microsoft.Agents.AI*` first. They are + our base. Then search the rest. + +**Prove the overlap** +- "The framework already does this" is proof only when a scratch probe + compiles and runs against the pinned version. +- Put the probe in the scratchpad. Do not commit it. +- Name the exact type and member in your report, such as + `DelegatingAIFunction`. +- If the probe fails, say what is missing, then write our own. +- Reason: in the 2026-08-26 framework audit, 4 of 5 "already covered" claims + were wrong. + +**A package we do not have yet** +- Propose the package before you write the code. Do not hand-roll it and + mention the package afterwards. +- Propose one when it replaces more than ~100 lines of our code, or when the + job is a known-hard domain: parsing, unicode, time zones, crypto, retry and + backoff, globbing, schema validation. +- Do not install it. Give the owner a short note with: + - the package, the exact version, and the licence + - what it replaces, in lines and files + - how many packages it pulls in, and any native binaries + - the date of the last release +- AgentCore ships as NuGet packages. Every dependency lands on every consumer. +- MIT, Apache-2.0, BSD, and MS-PL are safe. A copyleft licence (GPL, AGPL, + LGPL, MPL) is a stop. Report it and stop. +- Use a stable version. Prerelease needs the owner's OK. +- Pin the version in `Directory.Packages.props`. + +**Write our own when** +- No maintained package exists. +- The library type would leak into our public API. +- Our need is under ~20 lines and the package is not. + +Say which one applies in your report. diff --git a/.claude/rules/comments.md b/.claude/rules/comments.md new file mode 100644 index 00000000..110d50c2 --- /dev/null +++ b/.claude/rules/comments.md @@ -0,0 +1,74 @@ +--- +paths: + - "**/*.cs" + - "**/*.sql" + - "**/*.csproj" + - "**/*.props" + - "**/*.targets" +--- + +# Comments + +Write comments a maintainer needs. Do not write comments that repeat the code. + +**Default: no comment.** Code should say *what*. A comment says *why*. +Before you keep a comment, ask: "Would a good teammate be surprised here +without it?" If no, delete it. + +If you cannot name the reader and the surprise, do not write the comment. + +## Do write these + +- Why a non-obvious choice was made, when the obvious choice is wrong +- A workaround, with the cause and a link (bug report, issue, ticket) +- An invariant or precondition a caller must hold +- Units, ranges, and limits that the type does not show (`ms`, `0-1`, `UTC`) +- A pointer to the spec or design section a rule comes from +- A real safety warning: races, ordering, money, data loss +- Doc comments on public API, in the language's normal format + +## Never write these + +- Restating the line: `// increment the counter`, `// return the result` +- Step narration: `// Step 1: ...`, `// First we ..., then we ...` +- Changelog or diff talk: `// added for task 5`, `// was 30s before`, + `// new`, `// removed the old path` +- Talking to the reviewer: `// as requested`, `// note: I chose this because` +- Obvious types or names: `// the user id (string)` +- Banner art or divider lines: `// ===== HELPERS =====` +- Commented-out code. Delete it. Git has it. +- Bare `TODO`. Only keep a TODO with a ticket link. + +## Style + +- Match the comment density of the file you are editing. Do not add a new + style to an old file. +- One idea per comment. Keep it short. +- Say it in plain words. No hedging, no apologies, no jokes. +- Keep the comment next to the code it explains. +- If a comment explains a confusing block, try to fix the code first. A better + name or a small function often beats the comment. + +## Cleaning up old comments + +Fix comment slop in code you are **already editing**. Do not go hunting. + +**Scope** +- Only inside the function, block, or file you are changing for the task. +- Never open an unrelated file just to clean comments. +- Never make a "comment cleanup" commit unless the user asks for one. + +**Do this when you see it** +- Delete any comment that matches the "Never write these" list. +- Delete a comment that the code no longer matches. A stale comment is worse + than no comment. +- Delete commented-out code. +- If a comment says *why* but says it badly, keep the *why* and shorten it. + +**Do not touch these** +- Licence and copyright headers. +- Machine-read comments: `// eslint-disable`, `# type: ignore`, `#pragma`, + `// nolint`, ``, codegen markers. +- Doc comments that a doc tool publishes. +- A comment you do not understand. It may hold knowledge that is not in the + code. Leave it, and say so in your report. diff --git a/.claude/rules/csharp.md b/.claude/rules/csharp.md new file mode 100644 index 00000000..7dd0122e --- /dev/null +++ b/.claude/rules/csharp.md @@ -0,0 +1,48 @@ +--- +paths: + - "**/*.cs" +--- + +# C# + +## Style comes from the analyzers + +`.editorconfig` raises every IDE style rule to a suggestion, and SonarQube reports the rest. Those +diagnostics are the style guide. The owner ruled the repo's older habits to be slop, so follow the +diagnostic, not the code around it. + +- Clear every IDE and Sonar diagnostic in the code you touch. Most of them push you to the newer + C# form: collection expressions, pattern matching, `is null`, primary constructors. +- Namespaces are block-scoped. That is the Roslyn default here; do not convert a file to + file-scoped. +- `dotnet format style` is safe except for these fixers. Pass them to `--exclude-diagnostics` and + fix by hand: + - IDE0010, IDE0072: they write `throw new NotImplementedException()` stubs. Name every member + and keep `_ =>` for CS8524. + - IDE0130: crashes the workspace. Move or rename the file by hand. + - IDE0032: rewrites `ref _field` to `ref Property` and breaks `Interlocked`. + - IDE0001, IDE0002: shorten a cref to `ShellKind.X`, which is ambiguous with MAF Tools.Shell's + `ShellKind`. Keep the `using ShellKind = ...` alias. +- IDE0010 and Sonar S3458 conflict on empty cases before `default:`. Give the stacked group its + own body, or replace the switch with a `FrozenDictionary` (see `ConversationEventKinds`). + +## File size + +Keep each `.cs` file under 400 lines. + +**Scope** +- Apply this to files you are **already editing** for the task. Do not go hunting. +- If a file you must edit is over 400 lines, split it first, then make your change. +- Skip generated files and `*.schema.json`. + +**Rules** +- One public type per file. Name the file after the type. +- A private helper type gets its own file when it passes 20 lines. +- Split by responsibility. Move one cohesive group of members into its own + class, in the same namespace and folder. +- A split that only moves lines does not count. `FooPart2.cs` and a new + `partial` are not a split. +- `partial` is for source generators. + +If you cannot find a clean seam, stop and say so in your report. Do not +split at a random line. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md new file mode 100644 index 00000000..53bf2873 --- /dev/null +++ b/.claude/rules/tests.md @@ -0,0 +1,44 @@ +--- +paths: + - "tests/**" +--- + +# Tests + +When you finish adding or changing tests, run the `test-cleanup-audit` skill on the diff before you +report done. + +## Prove each new test fails when the code is wrong + +A test that passes whatever the code does proves nothing. Before you report done, do this for +every test you add or change: + +1. Break the production line the test guards: return the wrong value, skip the call, or flip + the setting. +2. Run the test. It must fail. +3. Put the line back. Check with `git diff` that the file is byte-identical to before. + +List each check in your report: the test, the line you broke, and the failure you saw. If a +test still passes, fix the test before you report done. + +## Live-server tests skip without a word + +`[PostgresFact]`, `[QdrantFact]`, and `[S3Fact]` skip when their variables are unset. A green +`dotnet test` then proves nothing about them. Name the variables to run them: + +- `AGENTCORE_TEST_POSTGRES`: a connection string. The role must be allowed to create databases + and roles. +- `AGENTCORE_TEST_QDRANT`: `host:port` of the **gRPC** port (6334), not 6333. +- `AGENTCORE_TEST_S3_ENDPOINT` and `AGENTCORE_TEST_S3_BUCKET`. + +A full run against one Qdrant reports about 26 false `RpcException: Timeout expired` failures in +`KbShapedCorpusFixture`. The 30 s gRPC deadline expires under load. Re-run the failing class with +`--filter` before you blame your change. Run `pgrep -f testhost.dll` first: a second run against +the same Qdrant always times out. + +## Conventions + +- Drive time with `FakeTimeProvider` and `WaitForTimersAsync`. A `Task.Delay` settle loop is + flaky. +- Mark a shared helper that asserts with `[AssertionMethod]` from `AgentCore.TestSupport`. + SonarQube S2699 honours it by name. diff --git a/.claude/rules/voice-components.md b/.claude/rules/voice-components.md new file mode 100644 index 00000000..96c136b6 --- /dev/null +++ b/.claude/rules/voice-components.md @@ -0,0 +1,132 @@ +--- +paths: + - "src/AgentCore.AspNetCore/Voice/**" + - "tests/AgentCore.AspNetCore.Tests/Voice/**" +--- + +# Voice components + +```mermaid +flowchart TB + classDef vendor fill:#fde7d9,stroke:#c2410c,color:#1d1d1f + classDef port fill:#e0ecff,stroke:#1d4ed8,color:#1d1d1f + classDef turn fill:#e6f6ea,stroke:#15803d,color:#1d1d1f + classDef speech fill:#f3e8ff,stroke:#7e22ce,color:#1d1d1f + classDef engine fill:#fff4c2,stroke:#a16207,color:#1d1d1f + + Caller(["📞 Caller via Telnyx"]) + + subgraph Route["Routing"] + MapCall["MapCall
/v1/{entry}/call"] + Boot["AgentCoreBoot
ConversationHandler"] + end + + subgraph Vendor["Vendors/TelnyxRelay (one per call)"] + Conn["TelnyxRelayConnection"] + Pump["JsonWebSocketPump
read loop"] + Reader["TelnyxRelayFrameReader"] + In["TelnyxRelayInput
(IConversationInputPort)"] + Out["TelnyxRelayOutput
(IConversationOutputPort)"] + Sender["JsonWebSocketSender
write loop"] + Obs["ConnectionTaskObserver"] + end + + subgraph Turn["Turn taking"] + Loop["VoiceConversationLoop"] + UTH["UserTurnHandler"] + Act["VoiceActivity"] + end + + subgraph Speech["Speech control"] + VS["VoiceSession
agent/user state, away timer"] + Sched["SpeechScheduler
queue + background speeches"] + Handle["SpeechHandle
one per speech"] + Pipe["PipelineReply
step loop"] + Say["SayReply
fixed text"] + Fwd["TextForwarding"] + Hear["ReplyHearing
what caller heard"] + Fill["ToolFillerScope
→ FillerScheduler"] + Met["TurnMetrics"] + end + + subgraph Engine["AgentCore.Application"] + Stream["EngineReplyStream"] + Sessions["IConversationSessions"] + CS["ConversationSession
(IConversationPort)"] + end + + Caller <-->|WebSocket JSON| MapCall + MapCall --> Boot --> Conn + Conn --> Pump & Sender & Loop + Pump --> Reader --> In + In -->|"ConversationInput:
Started / Utterance / Barge"| Loop + Loop -->|GetOrOpen / Close| Sessions --> CS + Loop -->|interim / final| UTH + Loop -->|Barge| Act + Loop -->|Barge: StopAsync| Out + UTH -->|TryGenerateReply| Act + UTH --> VS + Act --> Stream + Act --> Pipe + Act -->|schedule| Sched + VS --> Sched + VS -->|Say| Say + Sched -->|authorize| Handle + Pipe --> Handle + Pipe -->|read step text| Stream + Stream -->|StartTurnAsync / Cut| CS + Pipe --> Fwd -->|BeginReply / SpeakAsync / CompleteAsync| Out + Say --> Fwd + Pipe --> Hear -->|TurnCut| Stream + Pipe --> Fill -->|Say| VS + Pipe --> Met + Out --> Sender -->|JSON frames| Caller + Conn --> Obs + + class Conn,Pump,Reader,In,Out,Sender,Obs vendor + class MapCall,Boot port + class Loop,UTH,Act turn + class VS,Sched,Handle,Pipe,Say,Fwd,Hear,Fill,Met speech + class Stream,Sessions,CS engine +``` + +```mermaid +sequenceDiagram + autonumber + participant T as Telnyx + participant P as Pump + Input + participant L as VoiceConversationLoop + participant U as UserTurnHandler + participant A as VoiceActivity + participant S as SpeechScheduler + participant R as PipelineReply + participant E as EngineReplyStream → ConversationSession + participant O as Output + Sender + + T->>P: setup frame + P->>L: Started(conversationId) + L->>L: GetOrOpen session, make VoiceActivity + UserTurnHandler + T->>P: interim transcript + P->>L: Utterance(IsFinal=false) + L->>U: OnInterimTranscript → user Speaking + T->>P: final transcript + P->>L: Utterance(IsFinal=true) + L->>U: OnFinalTranscript + U->>A: InterruptByFinalTranscript (cut old speech) + U->>A: TryGenerateReply(text) + A->>E: Start engine turn + A->>S: ScheduleSpeech(handle) + S->>R: authorize speech + R->>R: wait for user silence + loop each model step + E-->>R: step text + R->>O: BeginReply / SpeakAsync / CompleteAsync + O->>T: speak frames + opt step calls a tool + R->>R: WaitForToolsAsync (filler may Say) + R->>S: re-schedule (force) for next step + end + end + R->>S: speech done → agent Listening, away timer armed + Note over T,O: Barge-in: Telnyx sends Barge → Loop calls Output.StopAsync
and VoiceActivity.InterruptByAudioActivity → ReplyHearing cuts the engine turn to what was heard +``` diff --git a/.claude/rules/voice.md b/.claude/rules/voice.md new file mode 100644 index 00000000..934ad23a --- /dev/null +++ b/.claude/rules/voice.md @@ -0,0 +1,13 @@ +--- +paths: + - "src/AgentCore.AspNetCore/Voice/**" + - "tests/AgentCore.AspNetCore.Tests/Voice/**" +--- + +# Voice + +Read `.claude/rules/voice-components.md` before you change this folder. It maps the parts of one phone +call: the three loops (read, listen, write), who decides *when* the agent talks, and where the +engine (`IConversationPort`) decides *what* it says. + +When your change adds, removes, or rewires a component, update the diagram in the same change. diff --git a/.gitignore b/.gitignore index b113dca4..fb3e616c 100644 --- a/.gitignore +++ b/.gitignore @@ -502,7 +502,6 @@ eval-report.html # Harness-created agent worktrees. Never commit these. .claude/worktrees/ -CLAUDE.md docs/ .superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ffd55276 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +AgentCore is a .NET 10 library. It ships as NuGet packages, so every dependency lands on every consumer. + +Rules for C#, comments, tests, and the Voice folder live in `.claude/rules/`. Each one loads when you open a file it covers. + +## Build and test + +- `dotnet build` fails on any warning. Fix the cause. Ask the owner before you add a `NoWarn`, a `#pragma warning disable`, or an `.editorconfig` severity change. +- `dotnet test` skips the Postgres, Qdrant, and S3 tests without a word when their `AGENTCORE_TEST_*` variables are unset. A green run does not cover them. See `.claude/rules/tests.md`. + +## Before you add an interface, wrapper, helper, or package + +Read `.claude/reference/reuse-before-you-build.md` first. + +## Agent skills + +### Issue tracker + +Issues and specs live as GitHub issues in MatthewHsu1/AgentCore, used through the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five default labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 2a3cb6a7..a5159055 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,6 +44,7 @@ + diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0 new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE-APACHE-2.0 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..2663dc34 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +AgentCore +Copyright 2026 Matthew Hsu + +This product includes software derived from LiveKit Agents +(https://github.com/livekit/agents), commit d8405f132e1bd960f298190c18daf81ffc1faf45. +Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. diff --git a/src/AgentCore.Application/AgentCore.Application.csproj b/src/AgentCore.Application/AgentCore.Application.csproj index e838bfc5..3e74153d 100644 --- a/src/AgentCore.Application/AgentCore.Application.csproj +++ b/src/AgentCore.Application/AgentCore.Application.csproj @@ -18,6 +18,7 @@ + @@ -39,6 +40,7 @@ + diff --git a/src/AgentCore.Application/Audit/QueuedAuditSink.cs b/src/AgentCore.Application/Audit/QueuedAuditSink.cs index 9590cbcd..49a692d9 100644 --- a/src/AgentCore.Application/Audit/QueuedAuditSink.cs +++ b/src/AgentCore.Application/Audit/QueuedAuditSink.cs @@ -1,9 +1,12 @@ +using System.Data.Common; using System.Threading.Channels; using AgentCore.Application.Diagnostics; using AgentCore.Application.Ports; using AgentCore.Domain.Audit; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Polly; +using Polly.Retry; namespace AgentCore.Application.Audit { @@ -18,10 +21,22 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa /// The most events the writer hands the store in one conversation. public const int DefaultBatchSize = 100; + /// How many times a batch the store refused with a transient fault is handed to it again. + private const int MaxRetryAttempts = 3; + + /// The median wait before the first retry. Each later wait doubles it, with jitter. + private static readonly TimeSpan FirstRetryDelay = TimeSpan.FromMilliseconds(200); + + /// The longest any one wait between retries can be. + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(2); + /// How long waits for the drain before it gives up. private static readonly TimeSpan DisposeTimeout = TimeSpan.FromSeconds(5); + private readonly ILogger _logger; + private readonly ResiliencePipeline _retry; + private readonly Channel _queue; private readonly CancellationTokenSource _stopping = new(); @@ -50,6 +65,9 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa /// /// The most events one call to the store carries, or . /// + /// + /// The clock the waits between retries run on, or for . + /// /// is . /// /// or is less than one. @@ -58,7 +76,8 @@ public QueuedAuditSink( IAuditSinkPort inner, ILogger? logger = null, int capacity = DefaultCapacity, - int batchSize = DefaultBatchSize) + int batchSize = DefaultBatchSize, + TimeProvider? timeProvider = null) { ArgumentNullException.ThrowIfNull(inner); ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); @@ -70,6 +89,8 @@ public QueuedAuditSink( _batchSize = batchSize; + _retry = RetryPipeline(timeProvider ?? TimeProvider.System); + _queue = Channel.CreateBounded(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait, @@ -118,20 +139,13 @@ public async ValueTask FlushAsync(CancellationToken cancellationToken = default) /// A task that completes when the drain is done. public async ValueTask DisposeAsync() { - // A container captures this once per service type it is registered under, and it answers to - // two. Without this guard the second close cancels a CancellationTokenSource the first one - // already disposed. if (Interlocked.Exchange(ref _closed, 1) != 0) { return; } - // Completing the writer is what ends the drain loop, and it is also what makes every later - // TryWrite return false. The queue keeps whatever is already in it, so the drain is honest. _ = _queue.Writer.TryComplete(); - // Bounded on purpose. A host that stops is never held by a store that will not answer, and a - // store that does answer gets the whole window to write what it already accepted. if (!await DrainedAsync().ConfigureAwait(false)) { await _stopping.CancelAsync().ConfigureAwait(false); @@ -190,14 +204,35 @@ private async Task WriteAsync() } } - /// Hands one batch to the store, and reports whatever it refuses. + /// Builds the retry a refused batch goes through before it is dropped. + /// The clock the waits between attempts run on. + /// The pipeline. + private static ResiliencePipeline RetryPipeline(TimeProvider timeProvider) + { + return new ResiliencePipelineBuilder { TimeProvider = timeProvider } + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder().Handle(exception => exception.IsTransient), + MaxRetryAttempts = MaxRetryAttempts, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + Delay = FirstRetryDelay, + MaxDelay = MaxRetryDelay, + }) + .Build(); + } + + /// Hands one batch to the store, retries a transient fault, and reports a batch it drops. /// The events to write, in the order they were accepted. /// A task that always completes, and never faults. private async Task AppendBatchAsync(List batch) { try { - await Store.AppendManyAsync([.. batch], _stopping.Token).ConfigureAwait(false); + await _retry.ExecuteAsync( + static (state, cancellationToken) => state.Store.AppendManyAsync(state.Events, cancellationToken), + (Store, Events: (IReadOnlyList)[.. batch]), + _stopping.Token).ConfigureAwait(false); } #pragma warning disable CA1031 // The store is a record of the conversation and never a part of it. catch (Exception exception) diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs index 3dd7c33a..b381ce9c 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs @@ -11,6 +11,7 @@ namespace AgentCore.Application.Configuration.Compilation /// /// Turns one declaration into the function the outer agent conversations. /// +#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. internal static class AgentDelegationTool { /// The argument name AsAIFunction() generates when the document declares no schema. @@ -19,20 +20,20 @@ internal static class AgentDelegationTool /// Builds the function that runs one declared agent. /// The kind: agent declaration. /// The already compiled inner agent. + /// + /// Every background provider the document compiled, so the delegation's fresh session releases its + /// background children when the tool returns. + /// /// The function the outer agent advertises. - internal static AIFunction Create(ToolConfiguration tool, AIAgent inner) + internal static AIFunction Create(ToolConfiguration tool, AIAgent inner, IReadOnlyList backgroundProviders) { - // The document names the function and describes it, because the calling model reads both to - // decide when to delegate. Without a description it falls back to the inner agent's own. - // AsAIFunction is the schema donor only — the wrapper below runs the agent directly, so - // its name, description and parameters stay exactly what the model was built against. AIFunction function = inner.AsAIFunction(new AIFunctionFactoryOptions { Name = tool.Id, Description = tool.Description ?? inner.Description, }); - return new DeclaredSchemaFunction(function, tool.Parameters, tool.Id, inner); + return new DeclaredSchemaFunction(function, tool.Parameters, tool.Id, inner, backgroundProviders); } /// @@ -51,13 +52,22 @@ private sealed class DeclaredSchemaFunction : DelegatingAIFunction private readonly AIAgent _inner; - internal DeclaredSchemaFunction(AIFunction inner, JsonNode? parameters, string toolId, AIAgent innerAgent) + private readonly IReadOnlyList _backgroundProviders; + + internal DeclaredSchemaFunction( + AIFunction inner, + JsonNode? parameters, + string toolId, + AIAgent innerAgent, + IReadOnlyList backgroundProviders) : base(inner) { ArgumentNullException.ThrowIfNull(toolId); + ArgumentNullException.ThrowIfNull(backgroundProviders); _toolId = toolId; _inner = innerAgent ?? throw new ArgumentNullException(nameof(innerAgent)); + _backgroundProviders = backgroundProviders; if (parameters is null) { @@ -84,24 +94,19 @@ public override JsonElement JsonSchema TurnInvocation? parent = TurnInvocation.FiledIn(arguments); - // Schemaless the donor advertises one query string; declared, the payload the - // document shaped. Either way the nested agent reads words, not arguments. string query = _argument is null && arguments.TryGetValue(DefaultArgument, out object? bare) && BareText(bare) is { } text ? text : ToolArgumentJson.ToJsonObject(arguments).ToJsonString(); - // Decided here where the parent turn is in hand: the nested run neither latches - // the holder (K42) nor records, and only the run under this tool is offered its - // tools. Without a turn the nested run is bare, as before. TurnInvocation? nested = parent is not null ? parent with { Clarifications = null, Nested = true } : null; IReadOnlyList? offered = parent?.ToolsFor == _toolId ? parent?.Tools : null; - return DelegatedAgentRun.RunAsync(_inner, query, nested, offered, cancellationToken); + return DelegatedAgentRun.RunAsync(_inner, query, nested, offered, _backgroundProviders, cancellationToken); } private static string? BareText(object? value) @@ -128,4 +133,5 @@ public override JsonElement JsonSchema } } +#pragma warning restore MAAI001 } diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs index c93fe5dd..3de0e9cf 100644 --- a/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs +++ b/src/AgentCore.Application/Configuration/Compilation/AgentToolCompiler.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace AgentCore.Application.Configuration.Compilation { +#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. internal static class AgentToolCompiler { public static List? Build( @@ -16,7 +17,8 @@ internal static class AgentToolCompiler Dictionary declared, AgentCompilationContext context, string pointer, - Func resolveAgent) + Func resolveAgent, + IReadOnlyList backgroundProviders) { if (item.Tools.Count == 0) { @@ -30,7 +32,7 @@ internal static class AgentToolCompiler string toolPointer = ConfigurationError.AppendPointer( ConfigurationError.AppendPointer(pointer, "tools"), index); - if (Resolve(id, declared, context.Tools, resolveAgent, toolPointer) is { } tool) + if (Resolve(id, declared, context.Tools, resolveAgent, backgroundProviders, toolPointer) is { } tool) { Add(tools, tool, item.Id, id, model, context); } @@ -45,6 +47,7 @@ internal static class AgentToolCompiler Dictionary declared, ToolRegistry? registry, Func resolveAgent, + IReadOnlyList backgroundProviders, string pointer) { if (!declared.TryGetValue(id, out ToolConfiguration? tool)) @@ -54,9 +57,7 @@ internal static class AgentToolCompiler if (tool.Kind == ToolKind.Agent) { - // A kind: agent tool needs no tool factory. Section 7 says section 8 adds no port, - // and this kind adds none either: the inner agent is already in the document. - return AgentDelegationTool.Create(tool, ResolveInner(tool, resolveAgent, pointer)); + return AgentDelegationTool.Create(tool, ResolveInner(tool, resolveAgent, pointer), backgroundProviders); } return Declared(id, registry, pointer); @@ -67,7 +68,6 @@ internal static class AgentToolCompiler { if (registry is null) { - // No factory, so nothing could have been built anyway. return null; } @@ -88,10 +88,6 @@ _ when registry.Contains(id) => registry.Resolve(id), } /// Adds one tool, unless it is a hosted marker its agent's model cannot run. - /// - /// Tested by type and not by the builtin name, so a hosted marker a host supplies through - /// its own IToolSource is covered by the same rule. - /// private static void Add( List tools, AITool tool, @@ -105,6 +101,7 @@ private static void Add( if (context.ChatClients.ResolveHostedTool(tool, model) is not { } resolved) { ILogger logger = context.Loggers?.CreateLogger(typeof(AgentToolCompiler)) ?? NullLogger.Instance; + string modelDescription = model is { Ref.Length: > 0 } ? $"the model '{model.Ref}'" : "this agent's default model"; Log.HostedToolDropped(logger, agentId, toolId, tool.GetType().Name, modelDescription); @@ -129,4 +126,5 @@ private static AIAgent ResolveInner(ToolConfiguration tool, Func - /// One row of the section 8.2 compile table. The row owns everything the compiler decides per - /// shape, so adding a shape is one new row and nothing else. + /// Turns one entry into the agent a turn runs. /// internal abstract class CompileTableRow { @@ -14,7 +13,6 @@ internal abstract class CompileTableRow internal abstract CompiledAgentShape Shape { get; } /// Gets whether the row answers its runs out of store 1 on its own session. - /// Rows 1 and 2 ride the session. A graph run rides the request messages. internal virtual bool SessionCarriesHistory => false; /// Builds the entry agent of one entry, and the stage table when the row has one. @@ -24,6 +22,11 @@ internal abstract class CompileTableRow /// The JSON Pointer to the entry, /entries/<name>. /// The compiled agents.items entries, keyed by id. /// The seams the document names. + /// + /// when a conversation on this entry keeps one MAF session across turns + /// (!SessionCarriesHistory && HarnessStateKeys.Count > 0, the same test + /// runs). Only the graph rows read it. + /// /// The agent a turn runs, and the agent id each policy.stages entry names. /// The entry does not compile through this row. internal abstract EntryBuild BuildEntry( @@ -32,7 +35,8 @@ internal abstract EntryBuild BuildEntry( EntryConfiguration entry, string entryPointer, Dictionary agents, - AgentCompilationContext context); + AgentCompilationContext context, + bool reusesGraphSession); /// Names the agents whose reply the caller actually hears. /// The loaded document. diff --git a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs index a6638381..1e5dd8b8 100644 --- a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs +++ b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs @@ -80,7 +80,7 @@ internal CompiledAgent(CompiledAgentBuilder builder) /// /// Gets the agents whose reply the caller hears, or for all of them. /// - internal IReadOnlySet? SpokenBy => _layers.SpokenBy; + internal IReadOnlySet? OutputAgents => _layers.OutputAgents; /// /// Gets store 1 of every conversation this agent answers. diff --git a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs index 54b04bc5..e28bfac0 100644 --- a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs +++ b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs @@ -10,7 +10,7 @@ namespace AgentCore.Application.Configuration.Compilation { /// - /// The compile table of section 8.2. It is a table, not a heuristic. + /// Compiles a loaded document into one agent per entry. /// public static class ConfigurationCompiler { @@ -104,13 +104,15 @@ public static IReadOnlyDictionary CompileAll( built[key] = shared; } - EntryBuild build = row.BuildEntry(configuration, entryName, entry, entryPointer, shared.Agents, context); + bool reusesGraphSession = !row.SessionCarriesHistory && shared.HarnessStateKeys.Count > 0; + EntryBuild build = row.BuildEntry(configuration, entryName, entry, entryPointer, shared.Agents, context, reusesGraphSession); TurnLayers layers = new( entry.FallbackReply ?? configuration.FallbackReply, entry.RefusalReply ?? configuration.RefusalReply, context.Moderation, - row.SpokenAuthors(configuration, entry)); + row.SpokenAuthors(configuration, entry), + document.History); compiled[entryName] = new CompiledAgentBuilder { @@ -141,6 +143,7 @@ private static CompiledAgentSet BuildAgents( Dictionary agents = new(StringComparer.Ordinal); HashSet harnessStateKeys = new(StringComparer.Ordinal); List backgroundProviders = []; + if (configuration.Agents is not { } section) { return new CompiledAgentSet(agents, harnessStateKeys, backgroundProviders); @@ -208,13 +211,14 @@ private static CompiledAgentSet BuildAgents( harnessStateKeys.UnionWith(AgentHarnessProviders.StateKeysOf(providers)); List? compiledTools = AgentToolCompiler.Build( - item, item.Model ?? section.Defaults?.Model, tools, context, pointer, Resolve); + item, item.Model ?? section.Defaults?.Model, tools, context, pointer, Resolve, backgroundProviders); harnessStateKeys.UnionWith(AgentApproval.StateKeysFor(section.Defaults, item, compiledTools)); ChatClientAgent built = new( WithToolFailureAuditing(context.ChatClients.GetChatClient(item.Model ?? section.Defaults?.Model)), new ChatClientAgentOptions { + Id = GraphExecutorId(item.Id), Name = item.Id, Description = item.Description, ChatOptions = new ChatOptions @@ -241,6 +245,14 @@ private static CompiledAgentSet BuildAgents( } #pragma warning restore MAAI001 + /// + /// Encodes an agents.items id into MAF's executor-id alphabet. + /// + private static string GraphExecutorId(string id) + { + return Convert.ToHexStringLower(System.Text.Encoding.UTF8.GetBytes(id)); + } + /// Puts the auditing function-invocation loop into the pipeline of one agent. private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatClient model) { @@ -251,6 +263,28 @@ private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatC .Build()); } + /// + /// Whether the named agents.items entry declares background:. + /// + internal static bool AgentDeclaresBackground(AgentCoreConfiguration configuration, string agentId) + { + return configuration.Agents?.Items.Any(item => string.Equals(item.Id, agentId, StringComparison.Ordinal) && item.Background.Count > 0) == true; + } + + /// + /// Builds check refusal for an agent that declares background: and is also used as a + /// graph node. Both graph rows raise this the same way, so the message lives once. + /// + internal static ConfigurationLoadException FailBackgroundInGraph(string pointer, string agentId, string entryName) + { + return Fail( + pointer, + $"the agent '{agentId}' declares background: and is also used as a node in the graph of " + + $"entry '{entryName}'. MAF has no way to release a graph node's session, so a " + + "background child started inside a node would keep running after the conversation " + + "ends. Remove background: from this agent, or stop using it as a graph node."); + } + internal static ConfigurationLoadException Fail(string pointer, string message) { return new(new ConfigurationError diff --git a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs index 95c72655..3af4aad6 100644 --- a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs @@ -8,7 +8,7 @@ namespace AgentCore.Application.Configuration.Compilation { /// - /// Row 4: the entry holds graph: with nodes: and edges:. It builds a + /// Holds graph: with nodes: and edges:. It builds a /// WorkflowBuilder, binds the agents as executors, then AsAIAgent(). /// internal sealed class ExplicitGraphRow : CompileTableRow @@ -23,15 +23,19 @@ internal override EntryBuild BuildEntry( EntryConfiguration entry, string entryPointer, Dictionary agents, - AgentCompilationContext context) + AgentCompilationContext context, + bool reusesGraphSession) { GraphConfiguration graph = entry.Graph!; string graphPointer = ConfigurationError.AppendPointer(entryPointer, "graph"); - (Dictionary? nodes, string? start, List? outputs) = BindNodes(graph, ConfigurationError.AppendPointer(graphPointer, "nodes"), agents); + + (Dictionary? nodes, string? start, List? outputs) = BindNodes( + configuration, entryName, graph, ConfigurationError.AppendPointer(graphPointer, "nodes"), agents); ExecutorBinding stateEntry = ExecutorBindingExtensions.BindExecutor(new GraphStateEntry()); WorkflowBuilder builder = new(stateEntry); + builder = builder.AddEdge(stateEntry, nodes[start]); (WorkflowBuilder? built, bool guarded) = AddEdges(builder, graph, ConfigurationError.AppendPointer(graphPointer, "edges"), nodes, context); @@ -40,11 +44,12 @@ internal override EntryBuild BuildEntry( built = built.WithOutputFrom([.. outputs]); } - AIAgent compiled = built.WithName(entryName) - .Build() - .AsAIAgent(name: entryName); + AIAgent compiled = built + .WithName(entryName) + .Build() + .AsAIAgent(name: entryName); - AIAgent withOutputCheck = new RequireOutputAgent(compiled, entryName); + AIAgent withOutputCheck = new RequireOutputAgent(new GraphFaultAgent(compiled, drain: reusesGraphSession), entryName); return new EntryBuild( guarded ? new GraphStateAgent(withOutputCheck, entryName) : withOutputCheck, @@ -53,6 +58,8 @@ internal override EntryBuild BuildEntry( /// Binds every node to its agent, and finds the one start node. private static (Dictionary Nodes, string Start, List Outputs) BindNodes( + AgentCoreConfiguration configuration, + string entryName, GraphConfiguration graph, string nodesPointer, Dictionary agents) @@ -64,7 +71,7 @@ private static (Dictionary Nodes, string Start, List Nodes, string Start, List agents, @@ -105,8 +114,11 @@ private static AIAgentBinding BindNode( $"the node id '{node.Id}' is declared twice."); } - // The measured shape: an agent binds as an executor, and the host emits update events so - // the wrapper can stream. Section 8.6. + if (ConfigurationCompiler.AgentDeclaresBackground(configuration, agentId)) + { + throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(pointer, "agent"), agentId, entryName); + } + return new AIAgentBinding(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = true }); } diff --git a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs index dea68cf7..b4d645c3 100644 --- a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs @@ -1,12 +1,13 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; namespace AgentCore.Application.Configuration.Compilation { /// - /// Row 3: the entry holds graph: with pattern:. It builds + /// Entry holds graph: with pattern:. It builds /// AgentWorkflowBuilder.BuildSequential, BuildConcurrent, /// CreateHandoffBuilderWith, or CreateGroupChatBuilderWith. /// @@ -22,7 +23,8 @@ internal override EntryBuild BuildEntry( EntryConfiguration entry, string entryPointer, Dictionary agents, - AgentCompilationContext context) + AgentCompilationContext context, + bool reusesGraphSession) { GraphConfiguration graph = entry.Graph!; string graphPointer = ConfigurationError.AppendPointer(entryPointer, "graph"); @@ -39,6 +41,11 @@ internal override EntryBuild BuildEntry( $"the graph names the agent '{id}', which agents.items does not declare."); } + if (ConfigurationCompiler.AgentDeclaresBackground(configuration, id)) + { + throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(agentsPointer, index), id, entryName); + } + participants.Add(agent); } @@ -56,7 +63,7 @@ internal override EntryBuild BuildEntry( _ => throw new ArgumentOutOfRangeException(nameof(entry), graph.Pattern, "The graph pattern vocabulary is closed, and this value is not in it."), }; - return new EntryBuild(workflow.AsAIAgent(name: entryName), NoStages()); + return new EntryBuild(new GraphFaultAgent(workflow.AsAIAgent(name: entryName), drain: reusesGraphSession), NoStages()); } /// diff --git a/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs b/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs index 6b02680d..68cdfb2e 100644 --- a/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/PolicyRow.cs @@ -5,7 +5,7 @@ namespace AgentCore.Application.Configuration.Compilation { /// - /// Row 2: the entry holds policy:. The machine picks a stage each turn, the stage names + /// Entry holds policy:. The machine picks a stage each turn, the stage names /// one agent, and that agent's run answers the caller. Runtime is Stateless. /// internal sealed class PolicyRow : CompileTableRow @@ -25,7 +25,8 @@ internal override EntryBuild BuildEntry( EntryConfiguration entry, string entryPointer, Dictionary agents, - AgentCompilationContext context) + AgentCompilationContext context, + bool reusesGraphSession) { PolicyConfiguration policy = entry.Policy!; string policyPointer = ConfigurationError.AppendPointer(entryPointer, "policy"); @@ -37,6 +38,7 @@ internal override EntryBuild BuildEntry( StageConfiguration stage = policy.Stages[index]; string stagePointer = ConfigurationError.AppendPointer( ConfigurationError.AppendPointer(policyPointer, "stages"), index); + if (stage.Agent is not { } agentId) { stages[stage.Id] = NoAgentId; diff --git a/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs b/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs index 3fdcf3a2..bccfc57b 100644 --- a/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs +++ b/src/AgentCore.Application/Configuration/Compilation/SingleAgentRow.cs @@ -5,7 +5,7 @@ namespace AgentCore.Application.Configuration.Compilation { /// - /// Row 1: the entry holds agent:. The named agents.items entry is the + /// Entry holds agent:. The named agents.items entry is the /// entry, and its run's own last message is the reply. /// internal sealed class SingleAgentRow : CompileTableRow @@ -22,7 +22,8 @@ internal override EntryBuild BuildEntry( EntryConfiguration entry, string entryPointer, Dictionary agents, - AgentCompilationContext context) + AgentCompilationContext context, + bool reusesGraphSession) { string agentPointer = ConfigurationError.AppendPointer(entryPointer, "agent"); diff --git a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs index 1cc232b2..050ed800 100644 --- a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs +++ b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs @@ -1,30 +1,39 @@ using AgentCore.Application.Evaluation; using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; using Microsoft.Agents.AI; namespace AgentCore.Application.Configuration.Compilation { - /// The two turn-disposition layers one entry puts on every agent a turn runs. + /// The turn layers one entry puts on every agent a turn runs: the two disposition layers, and the seal outside them. /// The resolved line the caller hears when a turn fails. /// The resolved line the caller hears when the agent refuses to answer. /// The endpoint seam, or to moderate nothing. - /// The agents whose reply the caller hears, or for all. + /// The agents whose reply the caller hears, or for all. + /// Store 1, which the seal commits every turn to. internal sealed record TurnLayers( string FallbackReply, string RefusalReply, PromptModerator? Moderation, - IReadOnlySet? SpokenBy) + IReadOnlySet? OutputAgents, + AgentCoreChatHistoryProvider History) { - /// Puts the layers on one agent. + /// Puts the layers on one agent, outermost first: the seal, then moderation, then the fallback. /// The compiled agent of one entry, or of one policy: stage. /// The agent the turn loop runs. public AIAgent Apply(AIAgent agent) { - AIAgent layered = new FallbackAgent(agent, FallbackReply, SpokenBy); + AIAgentBuilder builder = new AIAgentBuilder(agent) + .Use(inner => new ConversationTurnAgent(inner, History)); - return Moderation is null - ? layered - : new ModerationAgent(layered, Moderation, RefusalReply, ModerationAgent.DefaultTimeout); + if (Moderation is not null) + { + builder = builder.Use(inner => new ModerationAgent(inner, Moderation, RefusalReply, ModerationAgent.DefaultTimeout)); + } + + return builder + .Use(inner => new FallbackAgent(inner, FallbackReply, OutputAgents)) + .Build(); } } } diff --git a/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs b/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs index 61dc124f..5bc30955 100644 --- a/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs +++ b/src/AgentCore.Application/Configuration/Parsing/ConfigurationCheck.cs @@ -39,5 +39,12 @@ public enum ConfigurationCheck /// still carries a pointer into the document. /// ValueRange = 9, + + /// + /// Check 10: an agent used as a graph node may not declare background:. MAF 1.21.0 gives a + /// workflow node's session no release hook, so a background child started inside a node would + /// outlive the conversation. This table row has no entry in section 8.5. + /// + GraphNodeBackground = 10, } } diff --git a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs index e681189b..93b6e8cf 100644 --- a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs +++ b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs @@ -1,3 +1,7 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + namespace AgentCore.Application.Configuration.Schema { /// @@ -16,5 +20,18 @@ public sealed record ConversationProviderConfiguration /// Gets the largest inbound frame the socket accepts, in bytes, or null for the adapter's default. public int? MaxFrameBytes { get; init; } + + /// + /// Gets the caller-away prompt a voice adapter opens: { timeoutSeconds, say }. Voice conversations only. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public JsonElement UserAway { get; init; } + + /// + /// Gets the filler each tool id opens while its call is running, keyed by tool id. Voice + /// conversations only. A tool id this does not name opens no filler. + /// + public IReadOnlyDictionary Filler { get; init; } = + ReadOnlyDictionary.Empty; } } diff --git a/src/AgentCore.Application/Configuration/Schema/VoiceFillerConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/VoiceFillerConfiguration.cs new file mode 100644 index 00000000..a4b7e840 --- /dev/null +++ b/src/AgentCore.Application/Configuration/Schema/VoiceFillerConfiguration.cs @@ -0,0 +1,20 @@ +namespace AgentCore.Application.Configuration.Schema +{ + /// + /// What one tool id's filler says while its step runs, and how often. Voice conversations only. + /// + public sealed record VoiceFillerConfiguration + { + /// Gets what the agent says while the tool call runs. + public required string Say { get; init; } + + /// Gets how long the session must stay continuously idle before the first fire. + public required double DelaySeconds { get; init; } + + /// Gets the cooldown after a fire before the dwell restarts, or null to fire at most once. + public double? IntervalSeconds { get; init; } + + /// Gets the most fires across the tool call's lifetime, or null for no limit. + public int? MaxSteps { get; init; } + } +} diff --git a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json index 0cc2dcf4..7184c264 100644 --- a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json +++ b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json @@ -1365,6 +1365,62 @@ "minimum": 1, "maximum": 2147483647, "description": "The largest inbound frame the socket accepts. The ceiling is int.MaxValue, which is the largest buffer length the socket can be given." + }, + "userAway": { + "type": ["object", "null"], + "description": "When the caller counts as away, and what the agent says once they do. Voice conversations only. Omit the key to keep the shipped default (15 s, \"Are you still there?\"); write null to turn the away prompt off.", + "required": ["timeoutSeconds", "say"], + "additionalProperties": false, + "properties": { + "timeoutSeconds": { + "type": "number", + "exclusiveMinimum": 0, + "description": "How long both sides must stay idle before the caller is marked away." + }, + "say": { + "type": "string", + "minLength": 1, + "description": "What the agent says once the caller is marked away." + } + } + }, + "filler": { + "type": "object", + "description": "The filler each tool id opens while its call is running, keyed by tool id. Voice conversations only. A tool id this block does not name gets no filler.", + "propertyNames": { + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/$defs/voiceFiller" + } + } + } + }, + "voiceFiller": { + "type": "object", + "description": "What one tool id's filler says while its step runs, and how often.", + "required": ["say", "delaySeconds"], + "additionalProperties": false, + "properties": { + "say": { + "type": "string", + "minLength": 1, + "description": "What the agent says while the tool call runs." + }, + "delaySeconds": { + "type": "number", + "exclusiveMinimum": 0, + "description": "How long the session must stay continuously idle before the first fire." + }, + "intervalSeconds": { + "type": ["number", "null"], + "exclusiveMinimum": 0, + "description": "The cooldown after a fire before the dwell restarts. Omitted or null fires at most once." + }, + "maxSteps": { + "type": ["integer", "null"], + "minimum": 1, + "description": "The most fires across the tool call's lifetime. Omitted or null means no limit." } } }, diff --git a/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs b/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs index c9f76c12..6fb7989d 100644 --- a/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs +++ b/src/AgentCore.Application/Configuration/Validation/ConfigurationValidator.cs @@ -4,14 +4,20 @@ namespace AgentCore.Application.Configuration.Validation { /// - /// Checks 2 to 8 of section 8.5, over one bound document. Each check lives in its own - /// *Check class in this folder; this type only orders them and shapes the result. + /// Finds the mistakes in a loaded document that the JSON Schema cannot catch, such as a name + /// that points to nothing or a stage that no path reaches. /// public static class ConfigurationValidator { - /// Runs checks 2 to 8 and returns everything they find. - /// The bound document. - /// Every error and every partial-coverage warning. + /// + /// Runs , then checks each tool reference against the tools the + /// document declares. It never throws on a bad document; does. + /// + /// The loaded document. + /// + /// The errors, and the warnings. A warning does not stop the load. Some say a check had too + /// many cases to try them all, so it tried a random sample. + /// public static ConfigurationValidationResult Evaluate(AgentCoreConfiguration configuration) { ConfigurationValidationResult structural = EvaluateStructure(configuration); @@ -30,21 +36,21 @@ public static ConfigurationValidationResult Evaluate(AgentCoreConfiguration conf }; } - /// Runs checks 2 to 8 and throws when any of them fails. - /// The bound document. - /// The result, so a caller can read the partial-coverage warnings of check 5. - /// The document fails one or more checks. + /// Runs and throws when it finds an error. + /// The loaded document. + /// The result, so the caller can read the warnings. + /// The document has one or more errors. public static ConfigurationValidationResult Validate(AgentCoreConfiguration configuration) { return ThrowOnErrors(Evaluate(configuration)); } /// - /// Runs every check of section 8.5 except tool-reference resolution, and returns everything they - /// find. + /// Runs every check that needs only the document, and returns what they find. It skips tool + /// references, because MCP tools are not known until the servers answer. /// - /// The bound document. - /// Every error and every partial-coverage warning. + /// The loaded document. + /// The errors and the warnings, as describes them. public static ConfigurationValidationResult EvaluateStructure(AgentCoreConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); @@ -63,6 +69,7 @@ public static ConfigurationValidationResult EvaluateStructure(AgentCoreConfigura ExitExclusivityCheck.Run(configuration, errors, warnings); ReachabilityCheck.Run(configuration, errors); GraphWellFormednessCheck.Run(configuration, errors); + GraphNodeBackgroundCheck.Run(configuration, errors); DelegationCycleCheck.Run(configuration, errors); McpCheck.ServerIds(configuration, errors); McpCheck.SecretPlacement(configuration, errors); @@ -76,23 +83,20 @@ public static ConfigurationValidationResult EvaluateStructure(AgentCoreConfigura }; } - /// - /// Runs every check of section 8.5 except tool-reference resolution, and throws when any of them - /// fails. - /// - /// The bound document. - /// The result, so a caller can read the partial-coverage warnings of check 5. - /// The document fails one or more checks. + /// Runs and throws when it finds an error. + /// The loaded document. + /// The result, so the caller can read the warnings. + /// The document has one or more errors. public static ConfigurationValidationResult ValidateStructure(AgentCoreConfiguration configuration) { return ThrowOnErrors(EvaluateStructure(configuration)); } /// - /// Resolves every tool reference in the document against the ids the tool registry actually - /// serves, and throws when one names an id nothing serves. + /// Checks every tool reference against the tool ids the registry serves. Call it after the MCP + /// servers have listed their tools. /// - /// The bound document. + /// The loaded document. /// Every tool id the registry serves, declared and MCP-discovered alike. /// A reference names a tool id nothing serves. public static void ValidateToolReferences(AgentCoreConfiguration configuration, IReadOnlySet servedToolIds) @@ -106,11 +110,10 @@ public static void ValidateToolReferences(AgentCoreConfiguration configuration, } /// - /// Resolves every skills: and pinned: entry against the names the bound skills - /// folder serves. + /// Checks every skills: and pinned: entry against the skills in the skills folder. /// - /// The bound document. - /// Every skill name the bound folder serves. + /// The loaded document. + /// Every skill name in the skills folder. /// A reference names a skill nothing serves, or a skill is both pinned and loadable. public static void ValidateSkillReferences(AgentCoreConfiguration configuration, IReadOnlySet servedSkillNames) { @@ -124,11 +127,10 @@ public static void ValidateSkillReferences(AgentCoreConfiguration configuration, } /// - /// Refuses a declared tool id that the skills provider also registers. The provider's tools are - /// added per agent and never pass through the tool registry, so a collision is invisible until - /// the model receives two tools of one name. + /// Refuses a declared tool id that has the same name as a skills tool. Skills tools do not go + /// through the tool registry, so without this check the model would get two tools with one name. /// - /// The bound document. + /// The loaded document. /// A tool id collides with a skills tool name. public static void ValidateSkillToolNames(AgentCoreConfiguration configuration) { diff --git a/src/AgentCore.Application/Configuration/Validation/GraphNodeBackgroundCheck.cs b/src/AgentCore.Application/Configuration/Validation/GraphNodeBackgroundCheck.cs new file mode 100644 index 00000000..82c1473e --- /dev/null +++ b/src/AgentCore.Application/Configuration/Validation/GraphNodeBackgroundCheck.cs @@ -0,0 +1,61 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using static AgentCore.Application.Configuration.Validation.ValidationErrors; + +namespace AgentCore.Application.Configuration.Validation +{ + /// + /// An agent used as a node of any graph may not declare background:. + /// + internal static class GraphNodeBackgroundCheck + { + public static void Run(AgentCoreConfiguration configuration, List errors) + { + Dictionary nodeEntry = NodeAgentEntries(configuration); + IReadOnlyList items = configuration.Agents.Items; + + for (int index = 0; index < items.Count; index++) + { + AgentConfiguration agent = items[index]; + if (agent.Background.Count > 0 && nodeEntry.TryGetValue(agent.Id, out string? entryName)) + { + errors.Add(GraphNodeBackground( + ConfigurationError.AppendPointer(ValidationPointer.Agent(index), "background"), + $"the agent '{agent.Id}' declares background: and is also used as a node in the graph " + + $"of entry '{entryName}'. MAF has no way to release a graph node's session, so a " + + "background child started inside a node would keep running after the conversation " + + "ends. Remove background: from this agent, or stop using it as a graph node.")); + } + } + } + + /// The first entry name that binds each agent id as a graph node, pattern or explicit alike. + private static Dictionary NodeAgentEntries(AgentCoreConfiguration configuration) + { + Dictionary nodeEntry = new(StringComparer.Ordinal); + + foreach ((string? name, EntryConfiguration? entry) in configuration.Entries) + { + if (entry.Graph is not { } graph) + { + continue; + } + + foreach (string agentId in graph.Agents) + { + _ = nodeEntry.TryAdd(agentId, name); + } + + foreach (GraphNodeConfiguration node in graph.Nodes) + { + if (node.Agent is { } agentId) + { + _ = nodeEntry.TryAdd(agentId, name); + } + } + } + + return nodeEntry; + } + } +} diff --git a/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs b/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs index 58b265cb..51e47671 100644 --- a/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs +++ b/src/AgentCore.Application/Configuration/Validation/ValidationErrors.cs @@ -44,6 +44,11 @@ public static ConfigurationError WellFormedness(string pointer, string message) return new() { Pointer = pointer, Message = message, Check = ConfigurationCheck.GraphWellFormedness }; } + public static ConfigurationError GraphNodeBackground(string pointer, string message) + { + return new() { Pointer = pointer, Message = message, Check = ConfigurationCheck.GraphNodeBackground }; + } + /// Refuses a count or an interval that falls outside the range the runtime accepts. /// The configured value. /// The lowest accepted value, inclusive. diff --git a/src/AgentCore.Application/Conversation/ConversationInUseException.cs b/src/AgentCore.Application/Conversation/ConversationInUseException.cs new file mode 100644 index 00000000..e6356e81 --- /dev/null +++ b/src/AgentCore.Application/Conversation/ConversationInUseException.cs @@ -0,0 +1,48 @@ +namespace AgentCore.Application.Conversation +{ + /// + /// The session owner refused to open a conversation because another entry holds it live, or is opening it. + /// + public sealed class ConversationInUseException : InvalidOperationException + { + private const string DefaultMessage = "Another entry holds this conversation live, so the open was refused."; + + /// Creates the exception with its default message. + public ConversationInUseException() + : base(DefaultMessage) + { + } + + /// Creates the exception with a plain message. + /// The message a human reads. + public ConversationInUseException(string message) + : base(message) + { + } + + /// Creates the exception with a plain message and an inner cause. + /// The message a human reads. + /// The cause. + public ConversationInUseException(string message, Exception? innerException) + : base(message, innerException) + { + } + + /// Creates the exception for one refused open. + /// The id of the conversation that is held. + /// The entry that holds it live. + /// The entry whose open was refused. + public ConversationInUseException(string conversationId, string holdingEntry, string refusedEntry) + : base($"Entry '{holdingEntry}' holds conversation '{conversationId}' live, so entry '{refusedEntry}' was refused.") + { + ConversationId = conversationId; + HoldingEntry = holdingEntry; + } + + /// Gets the id of the conversation that is held, or when the thrower named none. + public string? ConversationId { get; } + + /// Gets the entry that holds the conversation live, or when the thrower named none. + public string? HoldingEntry { get; } + } +} diff --git a/src/AgentCore.Application/Conversation/ConversationTurnConflictException.cs b/src/AgentCore.Application/Conversation/ConversationTurnConflictException.cs new file mode 100644 index 00000000..e673c6f5 --- /dev/null +++ b/src/AgentCore.Application/Conversation/ConversationTurnConflictException.cs @@ -0,0 +1,33 @@ +namespace AgentCore.Application.Conversation +{ + /// + /// The store refused a turn's words because the conversation already saved that turn: another session of + /// the same conversation ran the same turn at the same time, and its commit landed first. Nothing of the + /// refused append was written. + /// + public sealed class ConversationTurnConflictException : InvalidOperationException + { + private const string DefaultMessage = "The conversation already saved this turn, so the store refused it."; + + /// Creates the exception with its default message. + public ConversationTurnConflictException() + : base(DefaultMessage) + { + } + + /// Creates the exception with a plain message. + /// The message a human reads. + public ConversationTurnConflictException(string message) + : base(message) + { + } + + /// Creates the exception with a plain message and an inner cause. + /// The message a human reads. + /// The cause. + public ConversationTurnConflictException(string message, Exception? innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/AgentCore.Application/Conversation/Conversations.cs b/src/AgentCore.Application/Conversation/Conversations.cs index a65e6153..5d999f22 100644 --- a/src/AgentCore.Application/Conversation/Conversations.cs +++ b/src/AgentCore.Application/Conversation/Conversations.cs @@ -186,6 +186,12 @@ public ValueTask RewriteAsync(string conversationId, string messageId, ChatMessa return Store.RewriteAsync(conversationId, messageId, content, cancellationToken); } + /// + public ValueTask DeleteMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) + { + return Store.DeleteMessageAsync(conversationId, messageId, cancellationToken); + } + /// public ValueTask> ReadForSessionAsync(string conversationId, CancellationToken cancellationToken = default) { @@ -222,6 +228,18 @@ public ValueTask SweepAsync(TimeSpan retention, int batchSize = 500, Cancel return Store.SweepAsync(retention, batchSize, cancellationToken); } + /// + public ValueTask TryMarkBusyAsync(string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + return Store.TryMarkBusyAsync(conversationId, holder, lease, cancellationToken); + } + + /// + public ValueTask ClearBusyAsync(string conversationId, string holder, CancellationToken cancellationToken = default) + { + return Store.ClearBusyAsync(conversationId, holder, cancellationToken); + } + /// public ValueTask AttachPrincipalAsync(string conversationId, string principalKey, string role, CancellationToken cancellationToken = default) { @@ -235,21 +253,21 @@ public ValueTask DetachPrincipalAsync(string conversationId, string principalKey } /// - public ValueTask SaveContinuationAsync(string continuationId, string conversationId, JsonElement envelope, CancellationToken cancellationToken = default) + public ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default) { - return Store.SaveContinuationAsync(continuationId, conversationId, envelope, cancellationToken); + return Store.SaveContinuationAsync(responseId, conversationId, cancellationToken); } /// - public ValueTask GetContinuationAsync(string continuationId, CancellationToken cancellationToken = default) + public ValueTask FindContinuationAsync(string responseId, CancellationToken cancellationToken = default) { - return Store.GetContinuationAsync(continuationId, cancellationToken); + return Store.FindContinuationAsync(responseId, cancellationToken); } /// - public ValueTask DeleteContinuationAsync(string continuationId, CancellationToken cancellationToken = default) + public ValueTask DeleteContinuationAsync(string responseId, CancellationToken cancellationToken = default) { - return Store.DeleteContinuationAsync(continuationId, cancellationToken); + return Store.DeleteContinuationAsync(responseId, cancellationToken); } } } diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationBusyMarks.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationBusyMarks.cs new file mode 100644 index 00000000..3e371c77 --- /dev/null +++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationBusyMarks.cs @@ -0,0 +1,48 @@ +namespace AgentCore.Application.Conversation.Memory +{ + /// The busy mark of each conversation the in-memory store holds, beside its rows rather than on them. + /// The clock a mark lapses on. + internal sealed class InMemoryConversationBusyMarks(TimeProvider time) + { + private readonly Lock _lock = new(); + + private readonly Dictionary _marks = []; + + /// Puts or extends a holder's mark, unless another holder's mark is still live. + /// The conversation to mark. + /// Who holds the mark. + /// How long the mark lives from now. + /// when now holds the mark. + public bool TryMark(string conversationId, string holder, TimeSpan lease) + { + lock (_lock) + { + DateTimeOffset now = time.GetUtcNow(); + + if (_marks.TryGetValue(conversationId, out (string Holder, DateTimeOffset Until) mark) + && mark.Holder != holder + && mark.Until > now) + { + return false; + } + + _marks[conversationId] = (holder, now + lease); + return true; + } + } + + /// Takes a holder's mark off, and leaves another holder's mark as it is. + /// The conversation to clear. + /// The id the mark was put under. + public void Clear(string conversationId, string holder) + { + lock (_lock) + { + if (_marks.TryGetValue(conversationId, out (string Holder, DateTimeOffset Until) mark) && mark.Holder == holder) + { + _ = _marks.Remove(conversationId); + } + } + } + } +} diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs index b3897e1e..a9cb5aca 100644 --- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs +++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationContinuations.cs @@ -1,70 +1,70 @@ -using System.Text.Json; - namespace AgentCore.Application.Conversation.Memory { /// - /// One serialized agent session per continuation id, held beside the in-memory store's conversations. - /// Every member runs under the owning store's lock; nothing here takes one. + /// One row per response id, held beside the in-memory store's conversations. /// internal sealed class InMemoryConversationContinuations { - private readonly Dictionary _envelopes = []; + private readonly Dictionary _rows = []; - /// Files one session envelope under one continuation id, replacing any envelope already there. - /// The continuation id: a conversation id or a response id. - /// The conversation the continuation belongs to. - /// The serialized session, as the agent wrote it. - /// When this write happened. A rewrite of the same id resets its retention clock. - public void Save(string continuationId, string conversationId, JsonElement envelope, DateTimeOffset updatedAt) + /// + /// Records that one response id continues one conversation. + /// + /// The response id to record. + /// The conversation the response id continues. + /// When this row was written, for the retention sweep. + public void Save(string responseId, string conversationId, DateTimeOffset createdAt) { - _envelopes[continuationId] = (conversationId, envelope.Clone(), updatedAt); + if (!_rows.ContainsKey(responseId)) + { + _rows[responseId] = (conversationId, createdAt); + } } - /// Reads the envelope one continuation id names. - /// The continuation id to look up. - /// The envelope, or when nothing is filed under that id. - public JsonElement? Get(string continuationId) + /// Finds the conversation one response id continues. + /// The response id to look up. + /// The conversation id, or when no row names that response id. + public string? Find(string responseId) { - return _envelopes.TryGetValue(continuationId, out (string ConversationId, JsonElement Envelope, DateTimeOffset UpdatedAt) row) - ? row.Envelope + return _rows.TryGetValue(responseId, out (string ConversationId, DateTimeOffset CreatedAt) row) + ? row.ConversationId : null; } - /// Withdraws whatever one continuation id names, if anything. - /// The continuation id to forget. - public void Forget(string continuationId) + /// Withdraws one response id, if it names a row. + /// The response id to forget. + public void Forget(string responseId) { - _ = _envelopes.Remove(continuationId); + _ = _rows.Remove(responseId); } - /// Withdraws every continuation filed under one conversation, however it was keyed. - /// The conversation whose continuations are gone. + /// Withdraws every response id filed under one conversation. + /// The conversation whose response ids are gone. public void ForgetConversation(string conversationId) { - foreach (string continuationId in _envelopes + foreach (string responseId in _rows .Where(pair => pair.Value.ConversationId == conversationId) .Select(pair => pair.Key) .ToList()) { - _ = _envelopes.Remove(continuationId); + _ = _rows.Remove(responseId); } } /// - /// Withdraws every continuation row untouched since before the cutoff, whichever conversation - /// it belongs to. + /// Withdraws every response id written before the cutoff, whichever conversation it continues. /// /// The point in time a row must have been written on or after, to stay. /// How many rows went. public int Sweep(DateTimeOffset cutoff) { - List going = [.. _envelopes - .Where(pair => pair.Value.UpdatedAt < cutoff) + List going = [.. _rows + .Where(pair => pair.Value.CreatedAt < cutoff) .Select(pair => pair.Key)]; - foreach (string continuationId in going) + foreach (string responseId in going) { - _ = _envelopes.Remove(continuationId); + _ = _rows.Remove(responseId); } return going.Count; diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs index 553ec0d8..47e40d91 100644 --- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs +++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs @@ -25,6 +25,8 @@ public sealed class InMemoryConversationStore(TimeProvider? timeProvider = null) private readonly TimeProvider _time = timeProvider ?? TimeProvider.System; + private readonly InMemoryConversationBusyMarks _busy = new(timeProvider ?? TimeProvider.System); + /// public ValueTask CreateAsync(string conversationId, CancellationToken cancellationToken = default) { @@ -151,6 +153,20 @@ public ValueTask SweepAsync( } } + /// + public ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(_busy.TryMark(conversationId, holder, lease)); + } + + /// + public ValueTask ClearBusyAsync(string conversationId, string holder, CancellationToken cancellationToken = default) + { + _busy.Clear(conversationId, holder); + return default; + } + /// public ValueTask AttachPrincipalAsync( string conversationId, string principalKey, string role, CancellationToken cancellationToken = default) @@ -205,12 +221,18 @@ public ValueTask> AppendAsync( } int fallbackTurnIndex = _state.GetValueOrDefault(conversationId)?.NextTurnIndex ?? 0; + if (messages.Min(message => message.TurnIndex) is { } named && named < fallbackTurnIndex) + { + throw new ConversationTurnConflictException( + $"Conversation '{conversationId}' already saved turn {named} and takes turn {fallbackTurnIndex} next, so nothing was written."); + } + IReadOnlyList rows = _words.Append(conversationId, messages, fallbackTurnIndex); DateTimeOffset now = _time.GetUtcNow(); _conversations[conversationId] = conversation with { LastMessageAt = now }; - if (state is not null) + if (state is not null && state.NextTurnIndex >= fallbackTurnIndex) { _state[conversationId] = state; } @@ -235,6 +257,20 @@ public ValueTask RewriteAsync( return default; } + /// + public ValueTask DeleteMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(conversationId); + ArgumentException.ThrowIfNullOrEmpty(messageId); + + lock (_lock) + { + _words.Delete(conversationId, messageId); + } + + return default; + } + /// public ValueTask> ReadForSessionAsync( string conversationId, CancellationToken cancellationToken = default) @@ -296,38 +332,38 @@ public ValueTask EraseAsync(string conversationId, CancellationToken cancel } /// - public ValueTask SaveContinuationAsync(string continuationId, string conversationId, JsonElement envelope, CancellationToken cancellationToken = default) + public ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(continuationId); + ArgumentNullException.ThrowIfNull(responseId); ArgumentNullException.ThrowIfNull(conversationId); lock (_lock) { - _continuations.Save(continuationId, conversationId, envelope, _time.GetUtcNow()); + _continuations.Save(responseId, conversationId, _time.GetUtcNow()); } return default; } /// - public ValueTask GetContinuationAsync(string continuationId, CancellationToken cancellationToken = default) + public ValueTask FindContinuationAsync(string responseId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(continuationId); + ArgumentNullException.ThrowIfNull(responseId); lock (_lock) { - return ValueTask.FromResult(_continuations.Get(continuationId)); + return ValueTask.FromResult(_continuations.Find(responseId)); } } /// - public ValueTask DeleteContinuationAsync(string continuationId, CancellationToken cancellationToken = default) + public ValueTask DeleteContinuationAsync(string responseId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(continuationId); + ArgumentNullException.ThrowIfNull(responseId); lock (_lock) { - _continuations.Forget(continuationId); + _continuations.Forget(responseId); } return default; diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs index d5b99ff9..8f0cd448 100644 --- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs +++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationWords.cs @@ -70,6 +70,21 @@ public void Rewrite(string conversationId, string messageId, ChatMessage content } } + /// Deletes one row, by the name the caller knows it by. The rows around it keep their ordinals. + /// The conversation the row belongs to. + /// The row to delete. + public void Delete(string conversationId, string messageId) + { + foreach ((string ConversationId, int Ordinal) key in _rows.Keys) + { + if (key.ConversationId == conversationId && _rows[key].MessageId == messageId) + { + _ = _rows.Remove(key); + break; + } + } + } + private IReadOnlyList Read(string conversationId) { return [.. Said(conversationId).OrderBy(row => row.Ordinal)]; diff --git a/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs b/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs index 4c8d9707..9b6df4c2 100644 --- a/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs +++ b/src/AgentCore.Application/Diagnostics/AgentCoreTelemetry.cs @@ -32,6 +32,9 @@ public static class AgentCoreTelemetry /// The failure kind of section 8.7, last row: the run returned quietly with no text. internal const string FailureEmptyReply = "empty_reply"; + /// The failure kind of a run that faulted outside every tool, say a model endpoint that did not answer. + internal const string FailureRun = "run"; + /// The failure kind of section 8.7, row two: the extractor returned an invalid object. internal const string FailureExtraction = "extraction"; @@ -48,10 +51,54 @@ public static class AgentCoreTelemetry private static readonly Meter Instruments = new(MeterName); + // Bucket boundaries for every agentcore.* histogram below, in seconds. The SDK default bounds are + // millisecond-shaped ([0,5,10,…,10000]) and put every reading here in the first bucket or two, which + // makes a percentile query meaningless (docs/probes/r2/otel/REPORT.md, audit finding F1). The values + // are the OpenTelemetry GenAI semantic conventions' own advice, so a backend that already has GenAI + // dashboards for gen_ai.client.operation.duration / gen_ai.server.time_to_first_token gets the same + // resolution for these: + // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-metrics.md + private static readonly InstrumentAdvice OperationDurationBuckets = new() + { + HistogramBucketBoundaries = + [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92], + }; + + private static readonly InstrumentAdvice TimeToFirstTokenBuckets = new() + { + HistogramBucketBoundaries = + [0.001, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0], + }; + private static readonly Histogram TurnDuration = Instruments.CreateHistogram( "agentcore.turn.duration", unit: "s", - description: "How long one turn took, from the caller's words to the spoken line."); + description: "How long one turn took, from the user's words to the reply.", + advice: OperationDurationBuckets); + + private static readonly Histogram TimeToFirstToken = Instruments.CreateHistogram( + "agentcore.turn.time_to_first_token", + unit: "s", + description: "From the start of one model step to its first text or tool call. One reading per step.", + advice: TimeToFirstTokenBuckets); + + private static readonly Histogram TimeToFirstSpeech = Instruments.CreateHistogram( + "agentcore.turn.time_to_first_speech", + unit: "s", + description: "From the user's final words to the first text of the reply handed to the output.", + advice: TimeToFirstTokenBuckets); + + private static readonly Histogram TimeToReplyEnd = Instruments.CreateHistogram( + "agentcore.turn.time_to_reply_end", + unit: "s", + description: "From the user's final words to the end of the last step of a reply nothing cut short.", + advice: OperationDurationBuckets); + + private static readonly Histogram BargeInLatency = Instruments.CreateHistogram( + "agentcore.barge_in.latency", + unit: "s", + description: "From the transport reporting a barge-in to every speech it interrupted being done.", + advice: TimeToFirstTokenBuckets); private static readonly Counter TurnFailures = Instruments.CreateCounter( "agentcore.turn.failures", @@ -98,13 +145,24 @@ public static class AgentCoreTelemetry /// One of the three closed outcome values. /// The stage the machine holds after the turn. /// The section 8.7 reason, or when the turn answered. + /// + /// The OTel error.type value for a failed turn: an exception's full type name, or one of the + /// closed failure-kind tokens when nothing threw. keeps + /// itself as the span status, so a fixed, non-PII reason (such as a refused-turn message) still reads + /// on the span. Never a value that can carry caller or model text: that belongs in the log, not here. + /// internal static void EndTurn( Activity? activity, TimeSpan elapsed, string outcome, string stageAfter, - string? failure) + string? failure, + string? errorType = null) { + // error.type never rides the metric point, even though the SPAN carries it below: an exception's + // type name is an open set (T61, and TurnObservabilityTests' PermittedMetricKeys), and a + // cumulative series per distinct type would grow without bound the same way one per conversation + // id would. TurnDuration.Record(elapsed.TotalSeconds, new KeyValuePair("agentcore.turn.outcome", outcome)); if (activity is null) @@ -121,14 +179,57 @@ internal static void EndTurn( if (failure is not null) { - // A failed turn still speaks a line, so the conversation is alive. The span says the turn failed - // and the reason names the row of section 8.7 it met. - _ = activity.SetStatus(ActivityStatusCode.Error, failure); + // A failed turn still speaks a line, so the conversation is alive. The span says the turn + // failed. Callers that pass errorType never also pass a failure built from an exception's + // message, so the status never carries caller or model text (owner ruling: type in spans, + // full error in logs). + _ = activity.SetStatus(ActivityStatusCode.Error, errorType ?? failure); + + if (errorType is not null) + { + _ = activity.SetTag("error.type", errorType); + } } } + /// Records how long the model took to say its first word of one turn. + /// Time since the transport handed over the user's words. + internal static void RecordTimeToFirstToken(TimeSpan elapsed) + { + TimeToFirstToken.Record(elapsed.TotalSeconds); + } + + /// Records how long one turn took to hand its first fragment back to the transport. + /// Time since the transport handed over the user's words. + /// + /// This is the last point the library can see. A transport that synthesises the audio itself + /// adds its own time to first sound on top, and no number here can reach it. + /// + internal static void RecordTimeToFirstSpeech(TimeSpan elapsed) + { + TimeToFirstSpeech.Record(elapsed.TotalSeconds); + } + + /// Records how long one whole reply took to hand over. + /// Time since the transport handed over the user's words. + /// + /// Only replies that ended on their own are recorded. A barge-in ends a turn before its close, + /// so an interrupted turn contributes nothing and cannot pull the distribution down. + /// + internal static void RecordTimeToReplyEnd(TimeSpan elapsed) + { + TimeToReplyEnd.Record(elapsed.TotalSeconds); + } + + /// Records how long a barge-in took to take effect. + /// Time from the transport reporting the barge-in to the queued reply being dropped. + internal static void RecordBargeInLatency(TimeSpan elapsed) + { + BargeInLatency.Record(elapsed.TotalSeconds); + } + /// Counts one turn that met a section 8.7 failure row. - /// One of the three closed failure kinds. + /// One of the four closed failure kinds. internal static void RecordFailure(string kind) { TurnFailures.Add(1, new KeyValuePair("agentcore.failure.kind", kind)); diff --git a/src/AgentCore.Application/Diagnostics/Log.cs b/src/AgentCore.Application/Diagnostics/Log.cs index d8edb07f..840cd3ba 100644 --- a/src/AgentCore.Application/Diagnostics/Log.cs +++ b/src/AgentCore.Application/Diagnostics/Log.cs @@ -5,7 +5,9 @@ namespace AgentCore.Application.Diagnostics { /// - /// Every line the library writes. Three of them are the "log once" rows of section 8.7. + /// Every line the turn loop writes: extraction, moderation, the transcript, and knowledge retrieval. Three + /// of them are the "log once" rows of section 8.7. The session owner's own lines — its workspace folder, + /// and its close and idle-expiry routine — are . /// internal static partial class Log { @@ -25,13 +27,27 @@ internal static partial class Log /// The logger of the session. /// The id of the conversation. /// The zero-based index of the turn that just ran. - /// The message of the fault. + /// The fault the fallback layer caught, message and stack trace both. [LoggerMessage( EventId = 2, Level = LogLevel.Error, - Message = "A tool of conversation {ConversationId} failed four times in turn {TurnIndex}: {Reason} " + Message = "A tool of conversation {ConversationId} failed four times in turn {TurnIndex}. " + "The turn spoke the fallback and the conversation continues.")] - public static partial void ToolBudgetSpent(ILogger logger, string conversationId, int turnIndex, string reason); + public static partial void ToolBudgetSpent(ILogger logger, string conversationId, int turnIndex, Exception exception); + + /// + /// Section 8.7, row six, the other cause of it: a fault above the fallback layer, so no tool ever ran. + /// + /// The logger of the session. + /// The id of the conversation. + /// The zero-based index of the turn that just ran. + /// The fault the run threw, message and stack trace both. + [LoggerMessage( + EventId = 28, + Level = LogLevel.Error, + Message = "The run of conversation {ConversationId} faulted in turn {TurnIndex}. " + + "The turn spoke the fallback and the conversation continues.")] + public static partial void TurnRunFaulted(ILogger logger, string conversationId, int turnIndex, Exception exception); /// Section 8.7, last row. The run returned quietly with no text. /// The logger of the session. @@ -113,18 +129,6 @@ internal static partial class Log + "The conversation continues and the turn has no durable record.")] public static partial void TranscriptWriteFailed(ILogger logger, string conversationId, int turnIndex, Exception exception); - /// A barge-in cut a reply, so the record now holds what the caller heard. - /// The logger of the session. - /// The id of the conversation. - /// The zero-based index of the turn whose reply was cut. - /// How much of the reply was played, as the vendor reported it. - [LoggerMessage( - EventId = 10, - Level = LogLevel.Debug, - Message = "A barge-in cut the reply of turn {TurnIndex} of conversation {ConversationId} " - + "after {PlayedMilliseconds} ms, so the record holds what the caller heard.")] - public static partial void ReplyTruncated(ILogger logger, string conversationId, int turnIndex, double playedMilliseconds); - /// One knowledge retrieval answered, with what it cost and what it returned. /// The logger of the knowledge provider. /// The id of the agent that asked. @@ -251,36 +255,6 @@ public static partial void KnowledgeScopeComposed( + "turn since the last read is not among them.")] public static partial void TranscriptResyncFailed(ILogger logger, string conversationId, int turnIndex, Exception exception); - /// A conversation's workspace folder could not be deleted when the conversation ended. - /// The logger of the session. - /// The folder that could not be deleted. - /// The cause. - [LoggerMessage( - EventId = 20, - Level = LogLevel.Warning, - Message = "Could not delete the conversation workspace at '{Path}'.")] - public static partial void WorkspaceDeleteFailed(ILogger logger, string path, Exception exception); - - /// A conversation's shell executor could not be disposed when the conversation ended. - /// The logger of the session. - /// The conversation's workspace folder, whose shell failed to dispose. - /// The cause. - [LoggerMessage( - EventId = 21, - Level = LogLevel.Warning, - Message = "Could not dispose a shell: executor of the conversation at workspace '{Workspace}'.")] - public static partial void ShellDisposeFailed(ILogger logger, string workspace, Exception exception); - - /// A conversation's background agent sessions could not be released when the conversation ended. - /// The logger of the session. - /// The id of the conversation whose children were being released. - /// The cause. - [LoggerMessage( - EventId = 22, - Level = LogLevel.Warning, - Message = "Could not release the background agent sessions of the conversation '{ConversationId}'.")] - public static partial void BackgroundReleaseFailed(ILogger logger, string conversationId, Exception exception); - /// A workspace file could not be published: the run had no call to own it. /// The logger of the tool. /// The tool the model called. @@ -331,5 +305,39 @@ public static partial void KnowledgeScopeComposed( Message = "The compaction strategy of conversation {ConversationId} produced, before turn {TurnIndex}, a shape one summary row " + "cannot stand for: not one new message over the oldest rows with the rest kept in order. The session keeps the view it had.")] public static partial void TranscriptCompactionUnsupported(ILogger logger, string conversationId, int turnIndex); + + /// A turn was refused or dropped, so none of its words were kept. + /// The logger of the session. + /// The id of the conversation. + /// The turn the session would have run. + /// The token. + [LoggerMessage( + EventId = 40, + Level = LogLevel.Warning, + Message = "Turn {TurnIndex} of conversation {ConversationId} was refused ({Reason}), so none of its words " + + "were kept.")] + public static partial void TurnRefused(ILogger logger, string conversationId, int turnIndex, string reason); + + /// The store could not put, renew or clear the conversation's busy mark. + /// The logger of the session. + /// The id of the conversation. + /// The cause. + [LoggerMessage( + EventId = 41, + Level = LogLevel.Warning, + Message = "The busy mark of conversation {ConversationId} could not be written. The turn runs on; the " + + "store still refuses a turn another session saved first.")] + public static partial void BusyMarkFailed(ILogger logger, string conversationId, Exception exception); + + /// A running turn found its busy mark taken by another session when it came to renew it. + /// The logger of the session. + /// The id of the conversation. + [LoggerMessage( + EventId = 42, + Level = LogLevel.Warning, + Message = "The busy mark of conversation {ConversationId} lapsed while a turn still ran, and another " + + "session took it. The store refuses whichever of the two turns saves second.")] + public static partial void BusyMarkLost(ILogger logger, string conversationId); + } } diff --git a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs b/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs index 0612797c..82b1be94 100644 --- a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs +++ b/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs @@ -9,7 +9,6 @@ namespace AgentCore.Application.Diagnostics /// /// Writes the "log once" rows of section 8.7, one line for each fact that earns one. /// - /// Creates the observer that writes the lines of section 8.7. /// /// Where the lines go, or for . The /// library never throws for want of one. @@ -28,37 +27,11 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C switch (conversationEvent.Kind) { - case ConversationEventKind.ToolFailed: - // Section 8.7, row six. The turn spoke the fallback and the conversation stays alive, so - // nothing here is fatal, and the level says Error because a spent retry budget is a - // defect in the tool. - // - // This kind is now raised at two altitudes, and only the TURN one earns a line. A - // fact that carries a tool call id is one failing conversation, and a turn spends up to four - // of them; the chain of D23 keeps every one, which is where a record of a conversation - // belongs, and section 8.7 asks for one line and not four. The turn-level fact - // carries no conversation id, because the fault reaches the session with no function name on - // it at all, so the absent key is exactly the test. - if (conversationEvent.Payload.ContainsKey(AuditPayloadKeys.ToolCallId)) - { - break; - } - - Log.ToolBudgetSpent( - _logger, - conversationEvent.ConversationId, - TurnOf(conversationEvent), - Detail(conversationEvent, AuditPayloadKeys.ToolError)); - break; - case ConversationEventKind.EmptyReply: - // Section 8.7, last row. On a voice call the silence is the failure. Log.EmptyReply(_logger, conversationEvent.ConversationId, TurnOf(conversationEvent)); break; case ConversationEventKind.ExtractionFailed: - // Section 8.7, row two. The slots stay unchanged and the conversation continues, so this is a - // warning and never an error. Log.ExtractionFailed( _logger, conversationEvent.ConversationId, @@ -75,7 +48,6 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C break; case ConversationEventKind.ModerationUnavailable: - // A vendor that did not answer is not a defect in this library, so it is a warning. Log.ModerationUnavailable( _logger, conversationEvent.ConversationId, @@ -84,27 +56,32 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C break; case ConversationEventKind.StateRestorePartial: - // Not a turn's fact and not section 8.7's: it happens once, as the conversation opens, and it - // carries no turn index. It earns a line all the same because it is the only signal - // there is. A document change costs a resumed conversation its stage or a slot, the chain - // stores no diagnostic kind, and the caller notices only that the agent has forgotten - // them — so without this line the fault is visible to nobody but a host that wrote an - // IConversationObserver of its own. Log.StateRestorePartial( _logger, conversationEvent.ConversationId, Detail(conversationEvent, ConversationEventPayloadKeys.Reason)); break; + case ConversationEventKind.TurnRefused: + Log.TurnRefused( + _logger, + conversationEvent.ConversationId, + TurnOf(conversationEvent), + Detail(conversationEvent, AuditPayloadKeys.RefusedReason)); + break; + case ConversationEventKind.ConversationStarted: case ConversationEventKind.ModerationClean: case ConversationEventKind.TurnCompleted: case ConversationEventKind.ReplyInterrupted: case ConversationEventKind.TurnSuperseded: - case ConversationEventKind.TranscriptWriteFailed: case ConversationEventKind.ConversationEnded: + break; + + case ConversationEventKind.TranscriptWriteFailed: case ConversationEventKind.TranscriptResyncFailed: - // The chain of D23 is the record of a normal conversation, and a log is not. + case ConversationEventKind.ToolFailed: + case ConversationEventKind.RunFaulted: break; default: @@ -126,11 +103,6 @@ private static int TurnOf(ConversationEvent conversationEvent) /// The fact being reported. /// The payload key the detail sits under. /// The detail, or an empty string when the fact carried none. - /// - /// A missing fact is an absent key and never the word "unknown", per the rule the turn loop keeps - /// for a payload. The line is still written: what happened is the point of it, and the detail is - /// the part a reader may have to do without. - /// private static string Detail(ConversationEvent conversationEvent, string key) { return conversationEvent.Payload.TryGetValue(key, out string? detail) ? detail : string.Empty; diff --git a/src/AgentCore.Application/Diagnostics/SessionOwnerLog.cs b/src/AgentCore.Application/Diagnostics/SessionOwnerLog.cs new file mode 100644 index 00000000..4dc703c2 --- /dev/null +++ b/src/AgentCore.Application/Diagnostics/SessionOwnerLog.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Diagnostics +{ + /// + /// Every line the session owner writes: a conversation's workspace folder, and the close or idle-expiry + /// routine that deletes it, disposes its shells, and releases its background sessions. + /// + internal static partial class SessionOwnerLog + { + /// A conversation's workspace folder could not be deleted when its session closed or unloaded. + /// The logger of the session. + /// The folder that could not be deleted. + /// The cause. + [LoggerMessage( + EventId = 20, + Level = LogLevel.Warning, + Message = "Could not delete the conversation workspace at '{Path}'.")] + public static partial void WorkspaceDeleteFailed(ILogger logger, string path, Exception exception); + + /// A conversation's shell executor could not be disposed when its session closed or unloaded. + /// The logger of the session. + /// The conversation's workspace folder, whose shell failed to dispose. + /// The cause. + [LoggerMessage( + EventId = 21, + Level = LogLevel.Warning, + Message = "Could not dispose a shell: executor of the conversation at workspace '{Workspace}'.")] + public static partial void ShellDisposeFailed(ILogger logger, string workspace, Exception exception); + + /// A conversation's background agent sessions could not be released when its session closed or unloaded. + /// The logger of the session. + /// The id of the conversation whose children were being released. + /// The cause. + [LoggerMessage( + EventId = 22, + Level = LogLevel.Warning, + Message = "Could not release the background agent sessions of the conversation '{ConversationId}'.")] + public static partial void BackgroundReleaseFailed(ILogger logger, string conversationId, Exception exception); + + /// A background provider's release waited its whole timeout: a child did not acknowledge its cancel. + /// The logger of the session. + /// The id of the conversation whose children were being released. + /// How long the release waited, in seconds. + [LoggerMessage( + EventId = 43, + Level = LogLevel.Warning, + Message = "A background child of the conversation '{ConversationId}' did not stop within {TimeoutSeconds} s " + + "of its cancel. The session was released anyway, and the child may still be running.")] + public static partial void BackgroundReleaseTimedOut(ILogger logger, string conversationId, double timeoutSeconds); + + /// An idle session could not be unloaded cleanly. + /// The logger of the session. + /// The id of the conversation that expired. + /// The cause. + [LoggerMessage( + EventId = 44, + Level = LogLevel.Error, + Message = "The idle conversation '{ConversationId}' did not unload cleanly. It is no longer held, but " + + "its last words, its shells, or its workspace folder may not have been cleaned up.")] + public static partial void SessionExpiryFailed(ILogger logger, string conversationId, Exception exception); + + /// The idle check could not read whether a background child is still running. + /// The logger of the session. + /// The id of the conversation. + /// The cause. + [LoggerMessage( + EventId = 45, + Level = LogLevel.Warning, + Message = "Could not read whether conversation '{ConversationId}' has a background child still running. " + + "The idle check treats it as one, and holds the session until a later poll can read it cleanly.")] + public static partial void BackgroundChildCheckFailed(ILogger logger, string conversationId, Exception exception); + + /// A conversation's workspace folder could not be stamped at the start of a turn. + /// The logger of the session. + /// The folder that could not be stamped. + /// The cause. + [LoggerMessage( + EventId = 46, + Level = LogLevel.Warning, + Message = "Could not stamp the conversation workspace at '{Path}'. The boot sweep of another process " + + "sharing this workspace root may mistake it for one a crashed process left behind.")] + public static partial void WorkspaceStampFailed(ILogger logger, string path, Exception exception); + + /// The boot sweep could not list the folders directly under the workspace root. + /// The boot's own logger. + /// The workspace root. + /// The cause. + [LoggerMessage( + EventId = 47, + Level = LogLevel.Warning, + Message = "The boot sweep could not list the folders under workspace root '{Root}'. No folder was swept this boot.")] + public static partial void WorkspaceSweepListFailed(ILogger logger, string root, Exception exception); + + /// The boot sweep found a folder older than the idle timeout, but could not delete it. + /// The boot's own logger. + /// The folder the sweep could not delete. + /// The cause. + [LoggerMessage( + EventId = 48, + Level = LogLevel.Warning, + Message = "The boot sweep could not delete the workspace folder '{Path}' left behind by a crashed process.")] + public static partial void WorkspaceSweepDeleteFailed(ILogger logger, string path, Exception exception); + + /// A conversation's workspace marker could not be written when its folder was created or reused. + /// The logger of the session. + /// The marker path that could not be written. + /// The cause. + [LoggerMessage( + EventId = 49, + Level = LogLevel.Warning, + Message = "Could not write the workspace marker at '{Path}'. The folder still works, but the boot sweep " + + "will never consider it for cleanup.")] + public static partial void WorkspaceMarkerWriteFailed(ILogger logger, string path, Exception exception); + + /// A conversation's workspace marker could not be deleted when its folder closed or unloaded. + /// The logger of the session. + /// The marker path that could not be deleted. + /// The cause. + [LoggerMessage( + EventId = 50, + Level = LogLevel.Warning, + Message = "Could not delete the workspace marker at '{Path}'.")] + public static partial void WorkspaceMarkerDeleteFailed(ILogger logger, string path, Exception exception); + } +} diff --git a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs b/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs index eff10692..984e22e0 100644 --- a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs +++ b/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs @@ -21,8 +21,6 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C break; case ConversationEventKind.ModerationUnavailable: - // The turn ran unchecked, because moderation fails open. This value is the only - // record of that, so an operator alerts on it rather than on a log line. AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationUnavailable); break; @@ -31,13 +29,21 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C break; case ConversationEventKind.ToolFailed: - AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureTool); + if (!conversationEvent.Payload.ContainsKey(AuditPayloadKeys.ToolCallId)) + { + AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureTool); + } + break; case ConversationEventKind.EmptyReply: AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureEmptyReply); break; + case ConversationEventKind.RunFaulted: + AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureRun); + break; + case ConversationEventKind.ExtractionFailed: AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureExtraction); break; @@ -46,31 +52,19 @@ public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, C case ConversationEventKind.TurnCompleted: case ConversationEventKind.ReplyInterrupted: case ConversationEventKind.TurnSuperseded: + case ConversationEventKind.TurnRefused: case ConversationEventKind.ConversationEnded: - // Counted below, as rows of the chain, and nowhere else. break; case ConversationEventKind.TranscriptWriteFailed: case ConversationEventKind.StateRestorePartial: case ConversationEventKind.TranscriptResyncFailed: - // Counted NOWHERE. No instrument would take them: agentcore.turn.failures counts what - // a TURN failed at, by a closed set of values T61 keeps closed because a new value costs - // a permanent series. A refused store 1 write is a fault of the system rather than of - // the turn, and a dropped slot happens before any turn has run, so each would need an - // instrument of its own and not another value on that one. They are reported as log - // lines instead, and saying so here is cheaper than an operator reading this switch and - // assuming a counter exists. break; default: break; } - // Section 8.6 counts the events the turn loop handed to the sink, by kind, and it counted them - // whatever the sink then did with them: the old conversation sat at the top of ConversationSession.Append, - // above the enqueue and above the try. A kind the chain does not store is not one of them, so - // the six diagnostic kinds are counted by their failure or their verdict above, or not at all, - // and never here. if (ConversationEventKinds.TryGetAuditKind(conversationEvent.Kind, out AuditEventKind auditKind)) { AgentCoreTelemetry.RecordAuditEvent(AuditEventKinds.ToToken(auditKind)); diff --git a/src/AgentCore.Application/Ports/IConversationAdapter.cs b/src/AgentCore.Application/Ports/IConversationAdapter.cs deleted file mode 100644 index 6a6522b7..00000000 --- a/src/AgentCore.Application/Ports/IConversationAdapter.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace AgentCore.Application.Ports -{ - /// - /// Names the vendor behind one providers.conversation value: who carries the conversation. - /// - /// - /// - /// This is the selection face, and it is all the core can see. The routing face - /// lives in AgentCore.AspNetCore as IConversationTransportAdapter, because it names - /// IEndpointRouteBuilder and this assembly references no ASP.NET Core package. - /// - /// - /// A vendor this process dials out to implements this and stops. It has no inbound URL, so - /// it is never forced to grow a routing member it would have to throw from. - /// - /// - public interface IConversationAdapter : IVendorAdapter - { - /// - /// Gets whether this transport's frames already carry text. - /// - /// - /// - /// means the vendor performs recognition and synthesis itself — the - /// Telnyx Conversation Relay does — so both speech roles, - /// providers.speech.stt.kind and providers.speech.tts.kind, must name that same - /// vendor, and enforces it. - /// - /// - /// means the frames carry audio and a separate speech vendor turns it - /// into text. That adapter runs recognition and synthesis inside itself: audio never leaves it, - /// so no audio type enters this assembly either way. - /// - /// - /// This is declared here rather than written in the document. A payload: text - /// field beside kind could only agree with the vendor or lie about it, and an adapter - /// cannot be wrong about its own wire. - /// - /// - bool CarriesText { get; } - } -} diff --git a/src/AgentCore.Application/Ports/IConversationInputPort.cs b/src/AgentCore.Application/Ports/IConversationInputPort.cs deleted file mode 100644 index c8e9ef5b..00000000 --- a/src/AgentCore.Application/Ports/IConversationInputPort.cs +++ /dev/null @@ -1,62 +0,0 @@ -using AgentCore.Application.Conversation; - -namespace AgentCore.Application.Ports -{ - /// - /// What the caller did, for one conversation, as one ordered stream. - /// - /// - /// - /// One instance serves one conversation and is disposed with it. That is unlike every other port in this - /// folder, which is bound once for the process, and it is why this one is - /// . - /// - /// - /// A barge-in arrives here, on the inbound stream, and not on - /// . It is an event the caller caused, and the order between "the - /// caller cut in" and "the caller then said this" is what a turn loop must never get wrong. One - /// ordered stream keeps that ordering inside the adapter, which is the only place that has the - /// information to settle it. - /// - /// - /// This port always carries text, whatever the wire carries. A bundled transport such as - /// the Telnyx Conversation Relay receives text frames and passes them straight through. A - /// transport whose socket carries audio runs recognition inside its own adapter and emits text - /// here. Audio stops at the adapter's edge and never enters this assembly, which is D28 and - /// section 11 item 6c. That is also why this port is named for the conversation and not for speech: - /// names the vendor behind each speech role — one vendor, or two — - /// and this does not. - /// - /// - public interface IConversationInputPort : IAsyncDisposable - { - /// Reads what the caller does, until the conversation ends. - /// - /// Abandons the read. Cancelling it throws, and never ends the stream quietly — see the remarks. - /// - /// Every inbound event of the conversation, in order. - /// - /// - /// One consumer only. This method may not be called a second time on one instance, and - /// a second call throws. is a factory rather than a stream, - /// so nothing in the type itself stops two enumerators from being taken over one socket, and - /// two consumers of one socket would each see half the conversation. - /// - /// - /// The conversation ending and the read being cancelled are two different endings, and every - /// implementation must tell them apart the same way. The conversation ending — the caller hung up, - /// the transport went away, the vendor closed the socket — completes the stream normally, and - /// that is the ordinary end of an await foreach over it. Cancelling - /// throws instead, - /// as any cancelled await does. A consumer therefore reads "the loop ended" as "the conversation is - /// over" and needs no flag to ask which happened, and an implementation may not swallow its - /// consumer's own cancellation into a clean ending: a test double that throws where the real - /// port completes, or the reverse, is a difference the whole point of this port is that nobody - /// above it can see. - /// - /// - /// This port is already being read. - /// was cancelled. - IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default); - } -} diff --git a/src/AgentCore.Application/Ports/IConversationOutputPort.cs b/src/AgentCore.Application/Ports/IConversationOutputPort.cs deleted file mode 100644 index 4b1ffaaa..00000000 --- a/src/AgentCore.Application/Ports/IConversationOutputPort.cs +++ /dev/null @@ -1,46 +0,0 @@ -using AgentCore.Application.Conversation; - -namespace AgentCore.Application.Ports -{ - /// - /// What the agent says, for one conversation. - /// - /// - /// - /// One instance serves one conversation and is disposed with it, as is. - /// - /// - /// Three methods and no events. Every studied voice framework gives its speaking side a method - /// and never a callback the core subscribes to, and the rule is worth more in C# than in the - /// languages they are written in: an event promises no ordering across threads. - /// - /// - public interface IConversationOutputPort : IAsyncDisposable - { - /// Speaks one fragment of the reply. - /// The text to speak. Never empty. - /// Stops waiting for room to send. - /// - /// One fragment, never a buffered sentence. A synthesizer that paces its own audio starts - /// speaking before the model finishes the reply, and buffering to a sentence would add the - /// whole first sentence to the time before the caller hears anything. - /// - ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default); - - /// Closes one reply, so the synthesizer knows no more is coming. - /// Stops waiting for room to send. - ValueTask CompleteAsync(CancellationToken cancellationToken = default); - - /// Starts stopping the reply, and drops whatever has not been spoken. - /// Stops waiting on the transport. - /// - /// This does not return the cut point, and the two are not simultaneous. The settled - /// answer — what the caller heard, and for how long — arrives afterwards as a - /// on the input stream. A vendor that reports the cut itself - /// settles at once. One built from a separate synthesizer must wait for its own audio to - /// finish draining first, and every implementation of that wait carries a deadline: two - /// seconds is the budget, after which the reply counts as fully heard. - /// - ValueTask StopAsync(CancellationToken cancellationToken = default); - } -} diff --git a/src/AgentCore.Application/Ports/IConversationPort.cs b/src/AgentCore.Application/Ports/IConversationPort.cs index 5c0ab7a6..1e974d72 100644 --- a/src/AgentCore.Application/Ports/IConversationPort.cs +++ b/src/AgentCore.Application/Ports/IConversationPort.cs @@ -1,3 +1,4 @@ +using AgentCore.Application.Runtime; using AgentCore.Domain; using AgentCore.Domain.Knowledge; using Microsoft.Extensions.AI; @@ -30,7 +31,7 @@ public interface IConversationPort /// Cancels the model calls. /// The finished turn. It always carries a spoken line. /// - /// The conversation already ended, or another turn of this conversation is still running. + /// The conversation already ended, or another turn of this conversation still ran after the wait limit. /// Task RunTurnAsync(string userInput, CancellationToken cancellationToken = default); @@ -39,7 +40,7 @@ public interface IConversationPort /// Cancels the model calls. /// The finished turn. Its reply is empty while approval requests are pending. /// - /// The conversation already ended, or another turn of this conversation is still running. + /// The conversation already ended, or another turn of this conversation still ran after the wait limit. /// Task RunTurnMessageAsync(ChatMessage userInput, CancellationToken cancellationToken); @@ -53,7 +54,7 @@ public interface IConversationPort /// 40-fragment reply, and an adapter must not have to filter them again. /// /// - /// The conversation already ended, or another turn of this conversation is still running. + /// The conversation already ended, or another turn of this conversation still ran after the wait limit. /// IAsyncEnumerable RunTurnStreamingAsync( string userInput, @@ -64,39 +65,50 @@ IAsyncEnumerable RunTurnStreamingAsync( /// Cancels the model calls. /// The reply, one update at a time. Every update carries content. /// - /// The conversation already ended, or another turn of this conversation is still running. + /// The conversation already ended, or another turn of this conversation still ran after the wait limit. /// IAsyncEnumerable RunTurnMessageStreamingAsync( ChatMessage userInput, CancellationToken cancellationToken); - /// Ends the running turn where the caller cut the reply off. - /// The text the caller actually heard. - /// How much of the reply played, as the relay reported it. - /// - /// Whether the turn running now is the turn the caller was hearing. An adapter that paces no - /// audio of its own leaves this at and lets the implementation decide. + /// Starts one turn, and hands back its index and its reply as it arrives. + /// What the caller said or answered: words, an approval answer, or both. + /// Where the turn hangs in the conversation the caller can see, or . + /// Cancels the turn, from the start through its last update. + /// + /// The started turn. reaches it by from now on. Enumerate + /// once: the turn runs and commits only then. Dispose the run, read or not: + /// the conversation is freed when the reply is read to the end or the run is disposed. + /// + /// + /// The conversation already ended, or another turn of this conversation still ran after the wait limit. + /// + Task StartTurnAsync( + ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken = default); + + /// Cuts one turn's reply where the user stopped seeing or hearing it (design section 3, the Cut rule). + /// The turn the user was seeing or hearing. + /// + /// What reached the user. keeps everything the turn yielded. /// /// - /// when the barge-in was recorded, either against the running turn or - /// against the turn that finished last, and when there was nothing to - /// record it against. + /// when the cut was recorded: a running turn stops and commits with it, and a committed + /// turn has its reply rewritten. when the turn is unknown or already cut, or when it is + /// older than the newest turn started on this conversation: once a later turn starts, an earlier turn's reply + /// never changes, even while the later turn is still running. /// - /// - /// - /// Item 6a of section 11 asks the record to hold what the caller heard, not what the model - /// produced. Section 7.1 reports both values on the interrupt frame, at 1 ms, so an - /// adapter passes them through and never computes them. Item 6c forbids the estimator that would - /// otherwise stand here. - /// - /// - /// carries one domain fact and no frame schema, so D8 holds. - /// A vendor that paces the audio itself is still speaking one turn while the next one already - /// runs — a held prompt starts it inside the finished turn's own ending — and only that adapter - /// can tell the two apart. It answers there, and the turn the caller was - /// actually hearing is the one the record corrects. - /// - /// - bool Interrupt(string utteranceUntilInterrupt, TimeSpan durationUntilInterrupt, bool cutsRunningTurn = true); + /// is negative. + bool Cut(int turnIndex, TurnCut cut); + + /// Replaces the cut a turn already took with a later account of what reached the user. + /// The turn the user was seeing or hearing. + /// What reached the user, as it is now known. + /// + /// when the recut was recorded: the turn commits with it, or its committed reply is + /// rewritten to it. when the turn is unknown or took no cut, or when it is older than + /// the newest turn started on this conversation, under the same rule as . + /// + /// is negative. + bool Recut(int turnIndex, TurnCut cut); } } diff --git a/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs b/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs index 8b5d2e00..1c0c8102 100644 --- a/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs +++ b/src/AgentCore.Application/Ports/IConversationSessionRegistry.cs @@ -1,17 +1,14 @@ namespace AgentCore.Application.Ports { /// - /// Finds the session store of one entry. + /// Finds the one session owner for the whole app, and the entries it serves. /// public interface IConversationSessionRegistry { /// Gets the names of the entries the document declares. IReadOnlyCollection Entries { get; } - /// Reads one entry's session store. - /// The entry key, as the document declares it. - /// The store for that entry. - /// The entry is not declared. - IConversationSessions ForSessions(string entry); + /// Gets the one session owner over every entry. + IConversationSessions Sessions { get; } } } diff --git a/src/AgentCore.Application/Ports/IConversationSessions.cs b/src/AgentCore.Application/Ports/IConversationSessions.cs index 950b6798..adc2032e 100644 --- a/src/AgentCore.Application/Ports/IConversationSessions.cs +++ b/src/AgentCore.Application/Ports/IConversationSessions.cs @@ -1,47 +1,64 @@ +using AgentCore.Application.Conversation; using AgentCore.Application.Runtime; namespace AgentCore.Application.Ports { /// - /// Owns the session of a conversation for as long as the conversation needs it. + /// Owns the session of a conversation for as long as the conversation needs it, for every entry the + /// document declares. One conversation id has at most one live session, under one entry. /// - /// - /// A conversation runs over many turns and one request carries one turn, so something has to hold the - /// session in between. This is that seam, and it owns the whole life of the session rather than - /// only the holding: a caller asks for the session of a conversation and never builds one itself. - /// public interface IConversationSessions { - /// Opens the session of one conversation. + /// + /// Returns the live session of one conversation under one entry, or opens it when none is held. + /// + /// The entry the session is held under, or opens under. /// The id the host gives the conversation, or to be given one. - /// Cancels the open. - /// The session, ready for its first turn. - ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default); + /// + /// The stored state to build the session from. It is used only when this call builds the session; a live + /// session keeps its own state, and this is ignored. + /// + /// + /// Cancels this caller's wait for a build or a close another caller started. That build or close runs on. + /// + /// + /// The session, ready for its next turn. Two callers that ask for the same id under the same entry at once + /// get the same session, built once. When the id's session is closing, this waits until its teardown is + /// done (its last words flushed, its shells stopped, its workspace folder deleted), then opens a new one. + /// + /// + /// is held, or being opened, under another entry. Nothing was built, and + /// that entry's session is left running, untouched. Once that session is gone (closed or unloaded), this + /// entry may open the id and read its history. + /// + ValueTask GetOrOpenAsync( + string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default); - /// Reads the session of one conversation, and marks the conversation as still live. + /// Reads the session of one conversation under one entry, and marks the conversation as still live. + /// The entry the session must be held under. /// The id the request named. It is the . /// Cancels the read. - /// The session, or when this holds no such conversation. - /// - /// A read is the only signal an implementation gets that a conversation is still being had, so an - /// implementation that expires an idle session must restart that session's clock here. A caller - /// that already holds the session for the whole conversation — the relay socket does — must therefore - /// still read it back on each turn, or its own conversation is the one that expires. - /// - ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default); + /// + /// The session, or when this holds no usable session of that id under this entry: + /// none is held, it is held under another entry, or it is still being built or already closing. A lookup + /// never waits and never closes anything. + /// + ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default); - /// Ends one conversation: waits for the words it still owes store 1, then drops it. - /// The id of the conversation that ended. - /// Cancels the close. - /// A task that completes once the session is gone and its writes are done. - /// - /// The order is the contract. A turn queues its rows and speaks, so a conversation can end with its last - /// turn still in flight, and the session is the only thing that can wait for those writes — once - /// it is dropped nothing can, and the durable record loses the turn the caller just had with no - /// error anywhere to say so. Every way a session ends comes through here, expiry included, so - /// no path can be written that skips the wait. An implementation must also dispose the session: - /// it owns processes (a conversation's shell: executors) that a dropped reference would leak. - /// - ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default); + /// + /// Closes one conversation's session: waits for the words it still owes store 1, disposes its background + /// children and shells, then deletes its workspace folder. This writes no conversation.ended + /// event; a caller that wants the close to be a real end calls + /// itself, before this. The id stays taken until the + /// teardown is done, so an open of it waits rather than race the folder delete. + /// + /// The entry the session must be held under. + /// The id of the conversation to close. + /// Cancels only a wait for a close another caller already started. + /// + /// A task that completes once the session is gone and its writes are done. When this holds no live session + /// of that id under this entry, nothing is closed. + /// + ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default); } } diff --git a/src/AgentCore.Application/Ports/IConversationStore.cs b/src/AgentCore.Application/Ports/IConversationStore.cs index 3ea1049a..d9373409 100644 --- a/src/AgentCore.Application/Ports/IConversationStore.cs +++ b/src/AgentCore.Application/Ports/IConversationStore.cs @@ -71,21 +71,21 @@ ValueTask ListAsync( /// alone. It rides with the words on purpose: a crash between the two would leave the stage /// behind the words it belongs to. For the same reason, a non- state is /// silently dropped when is empty: an empty turn writes no words for - /// it to ride with, so there is nothing to write it beside. + /// it to ride with, so there is nothing to write it beside. It is also dropped, while the words + /// land, when its is behind the stored state's: + /// a state never replaces a newer one. /// /// Cancels the write. /// /// The rows as written, in the order given, each with its ordinal and its turn index. /// - /// - /// The store numbers every row from the conversation's own counter, in one atomic step, so two writers on - /// two machines never collide on an ordinal — one from the conversation's live session, the other from a - /// host appending outside any turn. - /// /// /// The conversation named by does not exist. A row is never written against a /// conversation that has no row of its own. /// + /// + /// A message names a turn the conversation already saved. Nothing of the append is written: no row, and no state. + /// ValueTask> AppendAsync( string conversationId, IReadOnlyList messages, @@ -128,6 +128,18 @@ ValueTask RewriteAsync( ChatMessage content, CancellationToken cancellationToken = default); + /// + /// Deletes one already-written message, on a barge-in that leaves it saying and doing nothing. The rows + /// around it keep their ordinals. + /// + /// The conversation the message belongs to. + /// The message to delete. A message the conversation does not hold is no error. + /// Cancels the delete. + ValueTask DeleteMessageAsync( + string conversationId, + string messageId, + CancellationToken cancellationToken = default); + /// /// Reads a conversation's words as its session opens them: the newest summary row, and every row /// above what it covers. The rows a summary stands in for are not read. With no summary, every row. @@ -181,23 +193,54 @@ ValueTask TruncateAsync( ValueTask EraseAsync(string conversationId, CancellationToken cancellationToken = default); /// - /// Deletes every continuation row untouched past the retention window. + /// Deletes every response id written past the retention window. /// /// - /// How long a continuation row is kept, measured from when it was last written. The window belongs - /// to a deployment: it is not a schema key, and nothing here defaults it. + /// How long a response id is kept, measured from when it was written. The window belongs to a + /// deployment: it is not a schema key, and nothing here defaults it. /// /// - /// How many continuation rows one transaction may delete. The sweep loops until a batch deletes + /// How many response ids one transaction may delete. The sweep loops until a batch deletes /// nothing, so this bounds one transaction and never the work. /// /// Cancels the sweep between batches, and inside one. - /// How many continuation rows went, over every batch. + /// How many response ids went, over every batch. ValueTask SweepAsync( TimeSpan retention, int batchSize = 500, CancellationToken cancellationToken = default); + /// + /// Puts a holder's busy mark on a conversation, or extends the mark the holder already has, unless another + /// holder's mark is still live. + /// + /// + /// The conversation to mark. Its row need not exist: the first turn of a conversation marks it before making it. + /// + /// An opaque id of whoever holds the mark, the same on every renewal. + /// How long the mark lives from now, on the store's own clock. + /// Cancels the write. + /// + /// when now holds the mark; when + /// another holder's mark is still live, which is then left as it is. + /// + ValueTask TryMarkBusyAsync( + string conversationId, + string holder, + TimeSpan lease, + CancellationToken cancellationToken = default); + + /// + /// Takes a holder's busy mark off a conversation. A mark another holder has put since is left as it is. + /// + /// The conversation to clear. + /// The id the mark was put under. + /// Cancels the write. + ValueTask ClearBusyAsync( + string conversationId, + string holder, + CancellationToken cancellationToken = default); + /// Gives a principal a claim on a conversation. /// The conversation to claim. /// The opaque key that claims it. @@ -218,34 +261,30 @@ ValueTask DetachPrincipalAsync( string principalKey, CancellationToken cancellationToken = default); - /// Files one session envelope under one continuation id, replacing any envelope already there. - /// The continuation id: a conversation id or a response id. - /// The conversation the continuation belongs to. - /// The serialized session, as the agent wrote it. + /// + /// Records that one response id continues one conversation. + /// + /// The response id to record. + /// The conversation the response id continues. /// Cancels the write. - /// - /// The continuation names a conversation whose words live in store 1, so the map lives in this store: - /// a resume must never find the key without the words. - /// ValueTask SaveContinuationAsync( - string continuationId, + string responseId, string conversationId, - JsonElement envelope, CancellationToken cancellationToken = default); - /// Reads the envelope one continuation id names. - /// The continuation id to look up. + /// Finds the conversation one response id continues. + /// The response id to look up. /// Cancels the read. - /// The envelope, or when nothing is filed under that id. - ValueTask GetContinuationAsync( - string continuationId, + /// The conversation id, or when no row names that response id. + ValueTask FindContinuationAsync( + string responseId, CancellationToken cancellationToken = default); - /// Withdraws whatever one continuation id names, if anything. - /// The continuation id to forget. + /// Withdraws one response id, if it names a row. + /// The response id to forget. /// Cancels the delete. ValueTask DeleteContinuationAsync( - string continuationId, + string responseId, CancellationToken cancellationToken = default); } } diff --git a/src/AgentCore.Application/Ports/ISpeechAdapter.cs b/src/AgentCore.Application/Ports/ISpeechAdapter.cs deleted file mode 100644 index 39563412..00000000 --- a/src/AgentCore.Application/Ports/ISpeechAdapter.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace AgentCore.Application.Ports -{ - /// - /// Names the vendor behind one speech role's kind, stt or tts. - /// - /// - /// - /// This is the speech mirror of , , - /// , and , and it takes the same shape: - /// the host lists the vendors it supports once, providers.speech names which one the - /// document means for each role — stt is recognition and tts is synthesis — and a - /// document that changes vendors changes no code. - /// - /// - /// It declares no member of its own, so it carries a and - /// nothing else, on purpose. D15 makes every public member - /// a permanent obligation, so a member is added when a caller exists for it and not before. The only - /// vendor this solution ships is a bundled transport — the Telnyx Conversation Relay — and a relay - /// conversation is born from a socket the host already accepted: there is nothing for the adapter to create at - /// selection time, because the frames arriving on that socket are the channel. A create method here - /// today would be a signature no caller passes through and every future vendor must still honour. The - /// first split vendor, whose speech is a separate service the process must dial, is the caller that - /// earns the creation surface, and it adds it then. - /// - /// - /// So each role's kind is a naming field, not a selecting one: it answers "which - /// vendor did the document mean", and nothing here turns that answer into a constructed adapter. - /// is what reads the names today, and only to check them - /// against providers.conversation.kind — because a transport whose frames already carry text, such as - /// the relay this solution ships, is itself the recognizer and the synthesizer, so once the names - /// agree there is nothing left to build. stays the seam's - /// per-conversation face: it answers "open both halves of this one conversation", opened once for every conversation - /// rather than once while the host starts. - /// - /// - /// providers.conversation and providers.speech are required of one another. The schema - /// writes required: ["conversation", "speech"] on the providers object, so a document that - /// writes a providers section at all writes both, and AddAgentCoreAsync refuses a - /// configuration that registered a conversation transport and named no speech vendor. A transport whose - /// frames already carry text must further be named by both speech roles, because such a - /// vendor performs recognition and synthesis itself and a document naming it for the pipe and - /// something else for the ears or the mouth describes a deployment that cannot exist. - /// is what enforces that, while the host starts and before any - /// route is mapped, because the agreement is a fact about the document rather than about a route. - /// - /// - public interface ISpeechAdapter : IVendorAdapter - { - } -} diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index 357955ed..0321c888 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -10,27 +10,8 @@ AgentCore.Application.Audit.QueuedAuditSink.AppendAsync(AgentCore.Domain.Audit.A AgentCore.Application.Audit.QueuedAuditSink.Dispose() -> void AgentCore.Application.Audit.QueuedAuditSink.DisposeAsync() -> System.Threading.Tasks.ValueTask AgentCore.Application.Audit.QueuedAuditSink.FlushAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Audit.QueuedAuditSink.QueuedAuditSink(AgentCore.Application.Ports.IAuditSinkPort! inner, Microsoft.Extensions.Logging.ILogger? logger = null, int capacity = 10000, int batchSize = 100) -> void +AgentCore.Application.Audit.QueuedAuditSink.QueuedAuditSink(AgentCore.Application.Ports.IAuditSinkPort! inner, Microsoft.Extensions.Logging.ILogger? logger = null, int capacity = 10000, int batchSize = 100, System.TimeProvider? timeProvider = null) -> void AgentCore.Application.Audit.QueuedAuditSink.Store.get -> AgentCore.Application.Ports.IAuditSinkPort! -AgentCore.Application.Conversation.ConversationChannel -AgentCore.Application.Conversation.ConversationChannel.$() -> AgentCore.Application.Conversation.ConversationChannel! -AgentCore.Application.Conversation.ConversationChannel.ConversationChannel(AgentCore.Application.Ports.IConversationInputPort! Input, AgentCore.Application.Ports.IConversationOutputPort! Output) -> void -AgentCore.Application.Conversation.ConversationChannel.Deconstruct(out AgentCore.Application.Ports.IConversationInputPort! Input, out AgentCore.Application.Ports.IConversationOutputPort! Output) -> void -AgentCore.Application.Conversation.ConversationChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.ConversationChannel.Equals(AgentCore.Application.Conversation.ConversationChannel? other) -> bool -AgentCore.Application.Conversation.ConversationChannel.Input.get -> AgentCore.Application.Ports.IConversationInputPort! -AgentCore.Application.Conversation.ConversationChannel.Input.init -> void -AgentCore.Application.Conversation.ConversationChannel.Output.get -> AgentCore.Application.Ports.IConversationOutputPort! -AgentCore.Application.Conversation.ConversationChannel.Output.init -> void -AgentCore.Application.Conversation.ConversationChannelContext -AgentCore.Application.Conversation.ConversationChannelContext.$() -> AgentCore.Application.Conversation.ConversationChannelContext! -AgentCore.Application.Conversation.ConversationChannelContext.ConversationChannelContext(string! ConversationId, System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void -AgentCore.Application.Conversation.ConversationChannelContext.ConversationId.get -> string! -AgentCore.Application.Conversation.ConversationChannelContext.ConversationId.init -> void -AgentCore.Application.Conversation.ConversationChannelContext.CustomParameters.get -> System.Collections.Generic.IReadOnlyDictionary? -AgentCore.Application.Conversation.ConversationChannelContext.CustomParameters.init -> void -AgentCore.Application.Conversation.ConversationChannelContext.Deconstruct(out string! ConversationId, out System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void -AgentCore.Application.Conversation.ConversationChannelContext.Equals(AgentCore.Application.Conversation.ConversationChannelContext? other) -> bool AgentCore.Application.Conversation.ConversationClarificationState AgentCore.Application.Conversation.ConversationClarificationState.$() -> AgentCore.Application.Conversation.ConversationClarificationState! AgentCore.Application.Conversation.ConversationClarificationState.ConversationClarificationState() -> void @@ -38,32 +19,6 @@ AgentCore.Application.Conversation.ConversationClarificationState.Equals(AgentCo AgentCore.Application.Conversation.ConversationClarificationState.ProbeAsks.get -> int AgentCore.Application.Conversation.ConversationClarificationState.ProbeAsks.init -> void AgentCore.Application.Conversation.ConversationCursor -AgentCore.Application.Conversation.ConversationInput -AgentCore.Application.Conversation.ConversationInput.Barge -AgentCore.Application.Conversation.ConversationInput.Barge.Barge(string! HeardText, System.TimeSpan PlayedDuration) -> void -AgentCore.Application.Conversation.ConversationInput.Barge.Deconstruct(out string! HeardText, out System.TimeSpan PlayedDuration) -> void -AgentCore.Application.Conversation.ConversationInput.Barge.Equals(AgentCore.Application.Conversation.ConversationInput.Barge? other) -> bool -AgentCore.Application.Conversation.ConversationInput.Barge.HeardText.get -> string! -AgentCore.Application.Conversation.ConversationInput.Barge.HeardText.init -> void -AgentCore.Application.Conversation.ConversationInput.Barge.PlayedDuration.get -> System.TimeSpan -AgentCore.Application.Conversation.ConversationInput.Barge.PlayedDuration.init -> void -AgentCore.Application.Conversation.ConversationInput.ConversationInput(AgentCore.Application.Conversation.ConversationInput! original) -> void -AgentCore.Application.Conversation.ConversationInput.Keypress -AgentCore.Application.Conversation.ConversationInput.Keypress.Deconstruct(out string! Key) -> void -AgentCore.Application.Conversation.ConversationInput.Keypress.Equals(AgentCore.Application.Conversation.ConversationInput.Keypress? other) -> bool -AgentCore.Application.Conversation.ConversationInput.Keypress.Key.get -> string! -AgentCore.Application.Conversation.ConversationInput.Keypress.Key.init -> void -AgentCore.Application.Conversation.ConversationInput.Keypress.Keypress(string! Key) -> void -AgentCore.Application.Conversation.ConversationInput.Utterance -AgentCore.Application.Conversation.ConversationInput.Utterance.Deconstruct(out string! Text, out string! Language, out bool IsFinal) -> void -AgentCore.Application.Conversation.ConversationInput.Utterance.Equals(AgentCore.Application.Conversation.ConversationInput.Utterance? other) -> bool -AgentCore.Application.Conversation.ConversationInput.Utterance.IsFinal.get -> bool -AgentCore.Application.Conversation.ConversationInput.Utterance.IsFinal.init -> void -AgentCore.Application.Conversation.ConversationInput.Utterance.Language.get -> string! -AgentCore.Application.Conversation.ConversationInput.Utterance.Language.init -> void -AgentCore.Application.Conversation.ConversationInput.Utterance.Text.get -> string! -AgentCore.Application.Conversation.ConversationInput.Utterance.Text.init -> void -AgentCore.Application.Conversation.ConversationInput.Utterance.Utterance(string! Text, string! Language, bool IsFinal) -> void AgentCore.Application.Conversation.ConversationPage AgentCore.Application.Conversation.ConversationPage.$() -> AgentCore.Application.Conversation.ConversationPage! AgentCore.Application.Conversation.ConversationPage.ConversationPage(System.Collections.Generic.IReadOnlyList! Conversations, string? NextCursor) -> void @@ -96,6 +51,17 @@ AgentCore.Application.Conversation.ConversationRecord.Status.get -> AgentCore.Ap AgentCore.Application.Conversation.ConversationRecord.Status.init -> void AgentCore.Application.Conversation.ConversationRecord.Title.get -> string? AgentCore.Application.Conversation.ConversationRecord.Title.init -> void +AgentCore.Application.Conversation.ConversationInUseException +AgentCore.Application.Conversation.ConversationInUseException.ConversationId.get -> string? +AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException() -> void +AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException(string! conversationId, string! holdingEntry, string! refusedEntry) -> void +AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException(string! message) -> void +AgentCore.Application.Conversation.ConversationInUseException.ConversationInUseException(string! message, System.Exception? innerException) -> void +AgentCore.Application.Conversation.ConversationInUseException.HoldingEntry.get -> string? +AgentCore.Application.Conversation.ConversationTurnConflictException +AgentCore.Application.Conversation.ConversationTurnConflictException.ConversationTurnConflictException() -> void +AgentCore.Application.Conversation.ConversationTurnConflictException.ConversationTurnConflictException(string! message) -> void +AgentCore.Application.Conversation.ConversationTurnConflictException.ConversationTurnConflictException(string! message, System.Exception? innerException) -> void AgentCore.Application.Conversation.ConversationSessionState AgentCore.Application.Conversation.ConversationSessionState.$() -> AgentCore.Application.Conversation.ConversationSessionState! AgentCore.Application.Conversation.ConversationSessionState.ConversationSessionState() -> void @@ -116,7 +82,6 @@ AgentCore.Application.Conversation.ConversationSessionState.Version.get -> int AgentCore.Application.Conversation.ConversationSessionState.Version.init -> void AgentCore.Application.Conversation.ConversationSessionState.WorkflowState.get -> System.Text.Json.JsonElement? AgentCore.Application.Conversation.ConversationSessionState.WorkflowState.init -> void -AgentCore.Application.Conversation.ConversationSpeechPairing AgentCore.Application.Conversation.ConversationStateJson AgentCore.Application.Conversation.ConversationStatus AgentCore.Application.Conversation.ConversationStatus.Archived = 1 -> AgentCore.Application.Conversation.ConversationStatus @@ -131,16 +96,16 @@ AgentCore.Application.Conversation.Memory.InMemoryConversationStore.AppendAsync( AgentCore.Application.Conversation.Memory.InMemoryConversationStore.AttachPrincipalAsync(string! conversationId, string! principalKey, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.CreateAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DetachPrincipalAsync(string! conversationId, string! principalKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.EraseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Memory.InMemoryConversationStore.FindContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.GetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Memory.InMemoryConversationStore.GetContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.InMemoryConversationStore(System.TimeProvider? timeProvider = null) -> void AgentCore.Application.Conversation.Memory.InMemoryConversationStore.ListAsync(string! principalKey, string? after, int limit, AgentCore.Application.Conversation.ConversationStatus? status = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.RenameAsync(string! conversationId, string! title, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.RewriteAsync(string! conversationId, string! messageId, Microsoft.Extensions.AI.ChatMessage! content, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SaveContinuationAsync(string! continuationId, string! conversationId, System.Text.Json.JsonElement envelope, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SetCustomAsync(string! conversationId, System.Text.Json.JsonElement? custom, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Memory.InMemoryConversationStore.SetStatusAsync(string! conversationId, AgentCore.Application.Conversation.ConversationStatus status, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -232,6 +197,7 @@ AgentCore.Application.Configuration.Compilation.ResolvedKnowledge.Scoped.init -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck AgentCore.Application.Configuration.Parsing.ConfigurationCheck.DelegationCycles = 8 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck AgentCore.Application.Configuration.Parsing.ConfigurationCheck.DocumentSchema = 1 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck +AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GraphNodeBackground = 10 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GraphWellFormedness = 7 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GuardExclusivity = 5 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck AgentCore.Application.Configuration.Parsing.ConfigurationCheck.GuardOperators = 4 -> AgentCore.Application.Configuration.Parsing.ConfigurationCheck @@ -1041,42 +1007,32 @@ AgentCore.Application.Ports.IAuditSinkAdapter.OpenAsync(AgentCore.Application.Co AgentCore.Application.Ports.IAuditSinkPort AgentCore.Application.Ports.IAuditSinkPort.AppendAsync(AgentCore.Domain.Audit.AuditEvent! auditEvent, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IAuditSinkPort.AppendManyAsync(System.Collections.Generic.IReadOnlyList! auditEvents, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationAdapter -AgentCore.Application.Ports.IConversationAdapter.CarriesText.get -> bool -AgentCore.Application.Ports.IConversationChannelFactory -AgentCore.Application.Ports.IConversationChannelFactory.OpenAsync(AgentCore.Application.Conversation.ConversationChannelContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationInputPort -AgentCore.Application.Ports.IConversationInputPort.ListenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! AgentCore.Application.Ports.IConversationObserver AgentCore.Application.Ports.IConversationObserver.OnConversationEventAsync(AgentCore.Application.Runtime.ConversationEvent! conversationEvent, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationOutputPort -AgentCore.Application.Ports.IConversationOutputPort.CompleteAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationOutputPort.SpeakAsync(string! fragment, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationOutputPort.StopAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationSessionFactory AgentCore.Application.Ports.IConversationSessionFactory.Create(string? conversationId = null, AgentCore.Application.Conversation.ConversationSessionState? state = null) -> AgentCore.Application.Runtime.ConversationSession! AgentCore.Application.Ports.IConversationSessionRegistry AgentCore.Application.Ports.IConversationSessionRegistry.Entries.get -> System.Collections.Generic.IReadOnlyCollection! -AgentCore.Application.Ports.IConversationSessionRegistry.ForSessions(string! entry) -> AgentCore.Application.Ports.IConversationSessions! +AgentCore.Application.Ports.IConversationSessionRegistry.Sessions.get -> AgentCore.Application.Ports.IConversationSessions! AgentCore.Application.Ports.IConversationSessions -AgentCore.Application.Ports.IConversationSessions.CloseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationSessions.OpenAsync(string? conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationSessions.TryGetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationSessions.CloseAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationSessions.GetOrOpenAsync(string! entry, string? conversationId, AgentCore.Application.Conversation.ConversationSessionState? state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationSessions.TryGetAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore AgentCore.Application.Ports.IConversationStore.AppendAsync(string! conversationId, System.Collections.Generic.IReadOnlyList! messages, AgentCore.Application.Conversation.ConversationSessionState? state = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!> AgentCore.Application.Ports.IConversationStore.AppendMessageAsync(string! conversationId, Microsoft.Extensions.AI.ChatMessage! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.AttachPrincipalAsync(string! conversationId, string! principalKey, string! role, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.CreateAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.DeleteAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationStore.DeleteContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationStore.DeleteContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.DetachPrincipalAsync(string! conversationId, string! principalKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.EraseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.GetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationStore.GetContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationStore.FindContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.ListAsync(string! principalKey, string? after, int limit, AgentCore.Application.Conversation.ConversationStatus? status = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.RenameAsync(string! conversationId, string! title, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.RewriteAsync(string! conversationId, string! messageId, Microsoft.Extensions.AI.ChatMessage! content, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IConversationStore.SaveContinuationAsync(string! continuationId, string! conversationId, System.Text.Json.JsonElement envelope, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationStore.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversations.SweepAsync(System.TimeSpan retention, int batchSize = 500, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.SetCustomAsync(string! conversationId, System.Text.Json.JsonElement? custom, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.IConversationStore.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -1096,7 +1052,6 @@ AgentCore.Application.Ports.IChatClientFactory.GetContextWindow(AgentCore.Applic AgentCore.Application.Ports.IChatClientFactory.ResolveHostedTool(Microsoft.Extensions.AI.AITool! marker, AgentCore.Application.Configuration.Schema.ModelReference? model) -> Microsoft.Extensions.AI.AITool? AgentCore.Application.Ports.IConversationPort AgentCore.Application.Ports.IConversationPort.ConversationId.get -> string! -AgentCore.Application.Ports.IConversationPort.Interrupt(string! utteranceUntilInterrupt, System.TimeSpan durationUntilInterrupt, bool cutsRunningTurn = true) -> bool AgentCore.Application.Ports.IConversationPort.IsComplete.get -> bool AgentCore.Application.Ports.IConversationPort.LastTurn.get -> AgentCore.Domain.TurnResult? AgentCore.Application.Ports.IConversationPort.RunTurnAsync(string! userInput, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -1106,6 +1061,8 @@ AgentCore.Application.Ports.IConversationPort.RunTurnStreamingAsync(string! user AgentCore.Application.Ports.IConversationPort.Scope.get -> AgentCore.Domain.Knowledge.KnowledgeScope? AgentCore.Application.Ports.IConversationPort.Scope.set -> void AgentCore.Application.Ports.IConversationPort.Stage.get -> string! +AgentCore.Application.Ports.IConversationPort.Cut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool +AgentCore.Application.Ports.IConversationPort.Recut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool AgentCore.Application.Ports.IEmbeddingGeneratorAdapter AgentCore.Application.Ports.IEmbeddingGeneratorAdapter.CreateGeneratorAsync(AgentCore.Application.Configuration.Schema.EmbeddingProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask!>!> AgentCore.Application.Ports.IEvaluationScorePublisher @@ -1125,7 +1082,6 @@ AgentCore.Application.Ports.ISecretResolverPort AgentCore.Application.Ports.ISecretResolverPort.TryResolveAsync(string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.ISourcePort AgentCore.Application.Ports.ISourcePort.Publish(AgentCore.Domain.Sources.SourceReference! source) -> void -AgentCore.Application.Ports.ISpeechAdapter AgentCore.Application.Ports.ITelemetryAdapter AgentCore.Application.Ports.ITelemetryAdapter.StartAsync(AgentCore.Application.Configuration.Schema.TelemetryProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Ports.ITelemetrySession @@ -1150,8 +1106,8 @@ AgentCore.Application.Providers.VendorSeam.VendorSeam() -> void AgentCore.Application.Providers.VendorSeam.VendorSeam(string! DocumentPath, string! Pointer, string! RegistrationHint, string! Plural) -> void AgentCore.Application.Providers.VendorSeam.VendorSeam(string! documentPath, string! pointer, string! registrationHint) -> void AgentCore.Application.Runtime.AgentCoreAgent -AgentCore.Application.Runtime.AgentCoreAgent.AgentCoreAgent(AgentCore.Application.Ports.IConversationSessionFactory! sessions, string! entryName, string? description = null) -> void -AgentCore.Application.Runtime.AgentCoreAgent.CreateSessionAsync(string! conversationId) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Runtime.AgentCoreAgent.AgentCoreAgent(AgentCore.Application.Ports.IConversationSessions! sessions, string! entryName, string? description = null) -> void +AgentCore.Application.Runtime.AgentCoreAgent.CreateSessionAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Runtime.AgentCoreAgent.EntryName.get -> string! AgentCore.Application.Runtime.ConversationEvent AgentCore.Application.Runtime.ConversationEvent.$() -> AgentCore.Application.Runtime.ConversationEvent! @@ -1186,6 +1142,8 @@ AgentCore.Application.Runtime.ConversationEventKind.TranscriptResyncFailed = 13 AgentCore.Application.Runtime.ConversationEventKind.TranscriptWriteFailed = 10 -> AgentCore.Application.Runtime.ConversationEventKind AgentCore.Application.Runtime.ConversationEventKind.TurnCompleted = 7 -> AgentCore.Application.Runtime.ConversationEventKind AgentCore.Application.Runtime.ConversationEventKind.TurnSuperseded = 12 -> AgentCore.Application.Runtime.ConversationEventKind +AgentCore.Application.Runtime.ConversationEventKind.TurnRefused = 14 -> AgentCore.Application.Runtime.ConversationEventKind +AgentCore.Application.Runtime.ConversationEventKind.RunFaulted = 15 -> AgentCore.Application.Runtime.ConversationEventKind AgentCore.Application.Runtime.ConversationObservers AgentCore.Application.Runtime.ConversationSession AgentCore.Application.Runtime.ConversationSession.ConversationId.get -> string! @@ -1193,7 +1151,6 @@ AgentCore.Application.Runtime.ConversationSession.Compiled.get -> AgentCore.Appl AgentCore.Application.Runtime.ConversationSession.DisposeAsync() -> System.Threading.Tasks.ValueTask AgentCore.Application.Runtime.ConversationSession.EndConversation(AgentCore.Domain.Audit.ConversationEndReason reason) -> bool AgentCore.Application.Runtime.ConversationSession.FlushTranscriptAsync() -> System.Threading.Tasks.Task! -AgentCore.Application.Runtime.ConversationSession.Interrupt(string! utteranceUntilInterrupt, System.TimeSpan durationUntilInterrupt, bool cutsRunningTurn = true) -> bool AgentCore.Application.Runtime.ConversationSession.IsComplete.get -> bool AgentCore.Application.Runtime.ConversationSession.LastReplyMessageId.get -> string? AgentCore.Application.Runtime.ConversationSession.LastTurn.get -> AgentCore.Domain.TurnResult? @@ -1214,6 +1171,8 @@ AgentCore.Application.Runtime.ConversationSession.Transcript.get -> System.Colle AgentCore.Application.Runtime.ConversationSession.TryCreateApprovalAnswer(string! requestId, bool approved) -> Microsoft.Extensions.AI.ChatMessage? AgentCore.Application.Runtime.ConversationSession.TryCreateApprovalAnswerAsync(string! requestId, bool approved, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Runtime.ConversationSession.Workspace.get -> string? +AgentCore.Application.Runtime.ConversationSession.Cut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool +AgentCore.Application.Runtime.ConversationSession.Recut(int turnIndex, AgentCore.Application.Runtime.TurnCut! cut) -> bool AgentCore.Application.Runtime.ConversationSessionFactory AgentCore.Application.Runtime.ConversationSessionFactory.ConversationSessionFactory(AgentCore.Application.Configuration.Compilation.CompiledAgent! compiled, AgentCore.Application.Configuration.Validation.IGuardEvaluator! guards, AgentCore.Application.State.StateExtractor? extractor = null, System.TimeProvider? timeProvider = null, Microsoft.Extensions.Logging.ILogger? logger = null, System.Collections.Generic.IEnumerable? observers = null, string? workspaceRoot = null) -> void AgentCore.Application.Runtime.ConversationSessionFactory.Create(string? conversationId = null, AgentCore.Application.Conversation.ConversationSessionState? state = null) -> AgentCore.Application.Runtime.ConversationSession! @@ -1228,6 +1187,15 @@ AgentCore.Application.Runtime.ConversationTurnOrigin.NamesParent.get -> bool AgentCore.Application.Runtime.ConversationTurnOrigin.NamesParent.init -> void AgentCore.Application.Runtime.ConversationTurnOrigin.ParentMessageId.get -> string? AgentCore.Application.Runtime.ConversationTurnOrigin.ParentMessageId.init -> void +AgentCore.Application.Runtime.TurnCut +AgentCore.Application.Runtime.TurnCut.$() -> AgentCore.Application.Runtime.TurnCut! +AgentCore.Application.Runtime.TurnCut.Deconstruct(out string? ShownText, out System.TimeSpan? Played) -> void +AgentCore.Application.Runtime.TurnCut.Equals(AgentCore.Application.Runtime.TurnCut? other) -> bool +AgentCore.Application.Runtime.TurnCut.Played.get -> System.TimeSpan? +AgentCore.Application.Runtime.TurnCut.Played.init -> void +AgentCore.Application.Runtime.TurnCut.ShownText.get -> string? +AgentCore.Application.Runtime.TurnCut.ShownText.init -> void +AgentCore.Application.Runtime.TurnCut.TurnCut(string? ShownText, System.TimeSpan? Played) -> void AgentCore.Application.Scripting.ScriptRequest AgentCore.Application.Scripting.ScriptRequest.$() -> AgentCore.Application.Scripting.ScriptRequest! AgentCore.Application.Scripting.ScriptRequest.Code.get -> string! @@ -1267,12 +1235,12 @@ AgentCore.Application.Secrets.SecretResolutionException.SecretResolutionExceptio AgentCore.Application.Secrets.SecretResolutionException.SecretResolutionException(string! message, System.Exception? innerException) -> void AgentCore.Application.Secrets.SecretResolverExtensions AgentCore.Application.Sessions.Memory.InMemoryConversationSessions -AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.CloseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.CloseAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.Count.get -> int -AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.InMemoryConversationSessions(AgentCore.Application.Ports.IConversationSessionFactory! factory, System.TimeSpan idleTimeout, System.TimeProvider! timeProvider) -> void -AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.OpenAsync(string? conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.SweepAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.TryGetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.Dispose() -> void +AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.InMemoryConversationSessions(System.Collections.Generic.IReadOnlyDictionary! factories, System.TimeSpan idleTimeout, System.TimeProvider! timeProvider) -> void +AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.GetOrOpenAsync(string! entry, string? conversationId, AgentCore.Application.Conversation.ConversationSessionState? state, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.TryGetAsync(string! entry, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Skills.SkillCatalog AgentCore.Application.Skills.SkillCatalog.Names.get -> System.Collections.Generic.IReadOnlySet! AgentCore.Application.Skills.SkillCatalog.SkillCatalog(Microsoft.Agents.AI.AgentSkillsSource! source, System.Collections.Generic.IReadOnlySet! names) -> void @@ -1440,7 +1408,6 @@ AgentCore.Application.Transcript.SourceContent.Source.get -> AgentCore.Domain.So AgentCore.Application.Transcript.SourceContent.Source.set -> void AgentCore.Application.Transcript.SourceContent.SourceContent() -> void AgentCore.Application.Transcript.TranscriptJson -abstract AgentCore.Application.Conversation.ConversationInput.$() -> AgentCore.Application.Conversation.ConversationInput! abstract AgentCore.Application.Tools.DeclaredTool.CallAsync(Microsoft.Extensions.AI.AIFunctionArguments! arguments, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask const AgentCore.Application.Audit.AuditSinkFactory.MemoryKind = "memory" -> string! const AgentCore.Application.Audit.QueuedAuditSink.DefaultBatchSize = 100 -> int @@ -1491,6 +1458,7 @@ const AgentCore.Application.Knowledge.SourceLocatorCitationFormatter.FormatterNa const AgentCore.Application.Policy.StagePolicy.TurnEndedTrigger = "TurnEnded" -> string! const AgentCore.Application.Runtime.ConversationSession.EmptyReplyReason = "the run returned an empty reply, so the turn spoke the fallback." -> string! const AgentCore.Application.Runtime.ConversationSession.FallbackReply = "I am sorry. I could not finish that. Please say it again." -> string! +const AgentCore.Application.Runtime.ConversationSession.RunFaultReason = "the turn's run faulted, so it spoke the fallback." -> string! const AgentCore.Application.Runtime.ConversationSession.ToolFailureReason = "a tool failed four times in a row, so the turn spoke the fallback." -> string! const AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudApiTokenName = "grafana-cloud-api-token" -> string! const AgentCore.Application.Secrets.KnownSecrets.GrafanaCloudApiTokenVariable = "GRAFANA_CLOUD_API_TOKEN" -> string! @@ -1510,30 +1478,9 @@ const AgentCore.Application.Tools.Builtin.BuiltinToolNames.FilePublish = "file.p const AgentCore.Application.Tools.ToolErrorResult.ErrorProperty = "error" -> string! const AgentCore.Application.Tools.ToolErrorResult.MessageProperty = "message" -> string! const AgentCore.Application.Tools.ToolErrorResult.ToolProperty = "tool" -> string! -override AgentCore.Application.Conversation.ConversationChannel.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.ConversationChannel.GetHashCode() -> int -override AgentCore.Application.Conversation.ConversationChannel.ToString() -> string! -override AgentCore.Application.Conversation.ConversationChannelContext.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.ConversationChannelContext.GetHashCode() -> int -override AgentCore.Application.Conversation.ConversationChannelContext.ToString() -> string! override AgentCore.Application.Conversation.ConversationClarificationState.Equals(object? obj) -> bool override AgentCore.Application.Conversation.ConversationClarificationState.GetHashCode() -> int override AgentCore.Application.Conversation.ConversationClarificationState.ToString() -> string! -override AgentCore.Application.Conversation.ConversationInput.Barge.$() -> AgentCore.Application.Conversation.ConversationInput.Barge! -override AgentCore.Application.Conversation.ConversationInput.Barge.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.ConversationInput.Barge.GetHashCode() -> int -override AgentCore.Application.Conversation.ConversationInput.Barge.ToString() -> string! -override AgentCore.Application.Conversation.ConversationInput.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.ConversationInput.GetHashCode() -> int -override AgentCore.Application.Conversation.ConversationInput.Keypress.$() -> AgentCore.Application.Conversation.ConversationInput.Keypress! -override AgentCore.Application.Conversation.ConversationInput.Keypress.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.ConversationInput.Keypress.GetHashCode() -> int -override AgentCore.Application.Conversation.ConversationInput.Keypress.ToString() -> string! -override AgentCore.Application.Conversation.ConversationInput.ToString() -> string! -override AgentCore.Application.Conversation.ConversationInput.Utterance.$() -> AgentCore.Application.Conversation.ConversationInput.Utterance! -override AgentCore.Application.Conversation.ConversationInput.Utterance.Equals(object? obj) -> bool -override AgentCore.Application.Conversation.ConversationInput.Utterance.GetHashCode() -> int -override AgentCore.Application.Conversation.ConversationInput.Utterance.ToString() -> string! override AgentCore.Application.Conversation.ConversationPage.Equals(object? obj) -> bool override AgentCore.Application.Conversation.ConversationPage.GetHashCode() -> int override AgentCore.Application.Conversation.ConversationPage.ToString() -> string! @@ -1726,6 +1673,9 @@ override AgentCore.Application.Runtime.ConversationEvent.ToString() -> string! override AgentCore.Application.Runtime.ConversationTurnOrigin.Equals(object? obj) -> bool override AgentCore.Application.Runtime.ConversationTurnOrigin.GetHashCode() -> int override AgentCore.Application.Runtime.ConversationTurnOrigin.ToString() -> string! +override AgentCore.Application.Runtime.TurnCut.Equals(object? obj) -> bool +override AgentCore.Application.Runtime.TurnCut.GetHashCode() -> int +override AgentCore.Application.Runtime.TurnCut.ToString() -> string! override AgentCore.Application.Scripting.ScriptRequest.Equals(object? obj) -> bool override AgentCore.Application.Scripting.ScriptRequest.GetHashCode() -> int override AgentCore.Application.Scripting.ScriptRequest.ToString() -> string! @@ -1760,34 +1710,18 @@ override AgentCore.Application.Transcript.ConversationMessage.ToString() -> stri override AgentCore.Application.Transcript.ConversationMessageDraft.Equals(object? obj) -> bool override AgentCore.Application.Transcript.ConversationMessageDraft.GetHashCode() -> int override AgentCore.Application.Transcript.ConversationMessageDraft.ToString() -> string! -override sealed AgentCore.Application.Conversation.ConversationInput.Barge.Equals(AgentCore.Application.Conversation.ConversationInput? other) -> bool -override sealed AgentCore.Application.Conversation.ConversationInput.Keypress.Equals(AgentCore.Application.Conversation.ConversationInput? other) -> bool -override sealed AgentCore.Application.Conversation.ConversationInput.Utterance.Equals(AgentCore.Application.Conversation.ConversationInput? other) -> bool override sealed AgentCore.Application.Tools.DeclaredTool.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments! arguments, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask static AgentCore.Application.Audit.AuditSinkFactory.OpenAsync(AgentCore.Application.Configuration.Schema.AgentCoreConfiguration! configuration, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Collections.Generic.IReadOnlyList! adapters, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -static AgentCore.Application.Conversation.ConversationChannel.operator !=(AgentCore.Application.Conversation.ConversationChannel? left, AgentCore.Application.Conversation.ConversationChannel? right) -> bool -static AgentCore.Application.Conversation.ConversationChannel.operator ==(AgentCore.Application.Conversation.ConversationChannel? left, AgentCore.Application.Conversation.ConversationChannel? right) -> bool -static AgentCore.Application.Conversation.ConversationChannelContext.operator !=(AgentCore.Application.Conversation.ConversationChannelContext? left, AgentCore.Application.Conversation.ConversationChannelContext? right) -> bool -static AgentCore.Application.Conversation.ConversationChannelContext.operator ==(AgentCore.Application.Conversation.ConversationChannelContext? left, AgentCore.Application.Conversation.ConversationChannelContext? right) -> bool static AgentCore.Application.Conversation.ConversationClarificationState.operator !=(AgentCore.Application.Conversation.ConversationClarificationState? left, AgentCore.Application.Conversation.ConversationClarificationState? right) -> bool static AgentCore.Application.Conversation.ConversationClarificationState.operator ==(AgentCore.Application.Conversation.ConversationClarificationState? left, AgentCore.Application.Conversation.ConversationClarificationState? right) -> bool static AgentCore.Application.Conversation.ConversationCursor.Encode(System.DateTimeOffset sortAt, string! conversationId) -> string! static AgentCore.Application.Conversation.ConversationCursor.TryDecode(string? cursor, out System.DateTimeOffset sortAt, out string! conversationId) -> bool -static AgentCore.Application.Conversation.ConversationInput.Barge.operator !=(AgentCore.Application.Conversation.ConversationInput.Barge? left, AgentCore.Application.Conversation.ConversationInput.Barge? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.Barge.operator ==(AgentCore.Application.Conversation.ConversationInput.Barge? left, AgentCore.Application.Conversation.ConversationInput.Barge? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.Keypress.operator !=(AgentCore.Application.Conversation.ConversationInput.Keypress? left, AgentCore.Application.Conversation.ConversationInput.Keypress? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.Keypress.operator ==(AgentCore.Application.Conversation.ConversationInput.Keypress? left, AgentCore.Application.Conversation.ConversationInput.Keypress? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.Utterance.operator !=(AgentCore.Application.Conversation.ConversationInput.Utterance? left, AgentCore.Application.Conversation.ConversationInput.Utterance? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.Utterance.operator ==(AgentCore.Application.Conversation.ConversationInput.Utterance? left, AgentCore.Application.Conversation.ConversationInput.Utterance? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.operator !=(AgentCore.Application.Conversation.ConversationInput? left, AgentCore.Application.Conversation.ConversationInput? right) -> bool -static AgentCore.Application.Conversation.ConversationInput.operator ==(AgentCore.Application.Conversation.ConversationInput? left, AgentCore.Application.Conversation.ConversationInput? right) -> bool static AgentCore.Application.Conversation.ConversationPage.operator !=(AgentCore.Application.Conversation.ConversationPage? left, AgentCore.Application.Conversation.ConversationPage? right) -> bool static AgentCore.Application.Conversation.ConversationPage.operator ==(AgentCore.Application.Conversation.ConversationPage? left, AgentCore.Application.Conversation.ConversationPage? right) -> bool static AgentCore.Application.Conversation.ConversationRecord.operator !=(AgentCore.Application.Conversation.ConversationRecord? left, AgentCore.Application.Conversation.ConversationRecord? right) -> bool static AgentCore.Application.Conversation.ConversationRecord.operator ==(AgentCore.Application.Conversation.ConversationRecord? left, AgentCore.Application.Conversation.ConversationRecord? right) -> bool static AgentCore.Application.Conversation.ConversationSessionState.operator !=(AgentCore.Application.Conversation.ConversationSessionState? left, AgentCore.Application.Conversation.ConversationSessionState? right) -> bool static AgentCore.Application.Conversation.ConversationSessionState.operator ==(AgentCore.Application.Conversation.ConversationSessionState? left, AgentCore.Application.Conversation.ConversationSessionState? right) -> bool -static AgentCore.Application.Conversation.ConversationSpeechPairing.Validate(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! conversation, AgentCore.Application.Configuration.Schema.SpeechProviderConfiguration! speech, AgentCore.Application.Ports.IConversationAdapter! selectedConversation) -> void static AgentCore.Application.Conversation.ConversationStateJson.Options.get -> System.Text.Json.JsonSerializerOptions! static AgentCore.Application.Conversation.ConversationStoreFactory.OpenAsync(AgentCore.Application.Configuration.Schema.AgentCoreConfiguration! configuration, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Collections.Generic.IReadOnlyList! adapters, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static AgentCore.Application.Configuration.Compilation.AgentHarness.Compose(AgentCore.Application.Configuration.Schema.AgentDefaults? defaults, AgentCore.Application.Configuration.Schema.AgentConfiguration! agent) -> AgentCore.Application.Configuration.Compilation.ResolvedHarness! @@ -1968,6 +1902,8 @@ static AgentCore.Application.Runtime.ConversationObservers.Standard(AgentCore.Ap static AgentCore.Application.Runtime.ConversationSessionFactory.CreateExtractor(AgentCore.Application.Configuration.Compilation.CompiledAgent! compiled, AgentCore.Application.Ports.IChatClientFactory! chatClients) -> AgentCore.Application.State.StateExtractor? static AgentCore.Application.Runtime.ConversationTurnOrigin.operator !=(AgentCore.Application.Runtime.ConversationTurnOrigin? left, AgentCore.Application.Runtime.ConversationTurnOrigin? right) -> bool static AgentCore.Application.Runtime.ConversationTurnOrigin.operator ==(AgentCore.Application.Runtime.ConversationTurnOrigin? left, AgentCore.Application.Runtime.ConversationTurnOrigin? right) -> bool +static AgentCore.Application.Runtime.TurnCut.operator !=(AgentCore.Application.Runtime.TurnCut? left, AgentCore.Application.Runtime.TurnCut? right) -> bool +static AgentCore.Application.Runtime.TurnCut.operator ==(AgentCore.Application.Runtime.TurnCut? left, AgentCore.Application.Runtime.TurnCut? right) -> bool static AgentCore.Application.Scripting.ScriptRequest.operator !=(AgentCore.Application.Scripting.ScriptRequest? left, AgentCore.Application.Scripting.ScriptRequest? right) -> bool static AgentCore.Application.Scripting.ScriptRequest.operator ==(AgentCore.Application.Scripting.ScriptRequest? left, AgentCore.Application.Scripting.ScriptRequest? right) -> bool static AgentCore.Application.Scripting.ScriptResult.Failed(string! error) -> AgentCore.Application.Scripting.ScriptResult! @@ -2023,9 +1959,6 @@ static readonly AgentCore.Application.Secrets.KnownSecrets.OpenAi -> AgentCore.A static readonly AgentCore.Application.Secrets.KnownSecrets.PostgresConnectionString -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Secrets.KnownSecrets.Qdrant -> AgentCore.Application.Secrets.SecretName! static readonly AgentCore.Application.Sessions.Memory.InMemoryConversationSessions.DefaultIdleTimeout -> System.TimeSpan -virtual AgentCore.Application.Conversation.ConversationInput.EqualityContract.get -> System.Type! -virtual AgentCore.Application.Conversation.ConversationInput.Equals(AgentCore.Application.Conversation.ConversationInput? other) -> bool -virtual AgentCore.Application.Conversation.ConversationInput.PrintMembers(System.Text.StringBuilder! builder) -> bool virtual AgentCore.Application.Tools.Binding.ToolBinding.Invoke(System.Text.Json.Nodes.JsonObject! arguments, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask ~override AgentCore.Application.Providers.VendorSeam.Equals(object obj) -> bool ~override AgentCore.Application.Providers.VendorSeam.ToString() -> string @@ -2149,16 +2082,16 @@ AgentCore.Application.Conversation.Conversations.AttachPrincipalAsync(string! co AgentCore.Application.Conversation.Conversations.Conversations(AgentCore.Application.Ports.IConversationStore! conversations, AgentCore.Application.Ports.IBlobStore? blobs) -> void AgentCore.Application.Conversation.Conversations.CreateAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.DeleteAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Conversations.DeleteContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Conversations.DeleteContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.DetachPrincipalAsync(string! conversationId, string! principalKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.EraseAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.GetAsync(string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Conversations.GetContinuationAsync(string! continuationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Conversations.FindContinuationAsync(string! responseId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.LinkFilesAsync(string! conversationId, System.Collections.Generic.IEnumerable! messages, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! AgentCore.Application.Conversation.Conversations.ListAsync(string! principalKey, string? after, int limit, AgentCore.Application.Conversation.ConversationStatus? status = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.RenameAsync(string! conversationId, string! title, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.RewriteAsync(string! conversationId, string! messageId, Microsoft.Extensions.AI.ChatMessage! content, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Conversation.Conversations.SaveContinuationAsync(string! continuationId, string! conversationId, System.Text.Json.JsonElement envelope, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Conversations.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.SetCustomAsync(string! conversationId, System.Text.Json.JsonElement? custom, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.SetExternalIdAsync(string! conversationId, string? externalId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Application.Conversation.Conversations.SetStatusAsync(string! conversationId, AgentCore.Application.Conversation.ConversationStatus status, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -2353,3 +2286,40 @@ AgentCore.Application.Ports.IModelCatalogPort.LookupAsync(string! provider, stri const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyName = "opencode-go-api-key" -> string! const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyVariable = "OPENCODE_GO_API_KEY" -> string! static readonly AgentCore.Application.Secrets.KnownSecrets.OpenCodeGo -> AgentCore.Application.Secrets.SecretName! +AgentCore.Application.Ports.IConversationPort.StartTurnAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.ConversationSession.StartTurnAsync(Microsoft.Extensions.AI.ChatMessage! userInput, AgentCore.Application.Runtime.ConversationTurnOrigin? origin, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +AgentCore.Application.Runtime.TurnRun +AgentCore.Application.Runtime.TurnRun.DisposeAsync() -> System.Threading.Tasks.ValueTask +AgentCore.Application.Runtime.TurnRun.TurnIndex.get -> int +AgentCore.Application.Runtime.TurnRun.TurnRun(int turnIndex, System.Collections.Generic.IAsyncEnumerable! updates) -> void +AgentCore.Application.Runtime.TurnRun.Updates.get -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Filler.get -> System.Collections.Generic.IReadOnlyDictionary! +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.Filler.init -> void +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.UserAway.get -> System.Text.Json.JsonElement +AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration.UserAway.init -> void +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.$() -> AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration! +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.DelaySeconds.get -> double +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.DelaySeconds.init -> void +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.Equals(AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration? other) -> bool +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.IntervalSeconds.get -> double? +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.IntervalSeconds.init -> void +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.MaxSteps.get -> int? +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.MaxSteps.init -> void +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.Say.get -> string! +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.Say.init -> void +AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.VoiceFillerConfiguration() -> void +override AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.Equals(object? obj) -> bool +override AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.GetHashCode() -> int +override AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.ToString() -> string! +static AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.operator !=(AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration? left, AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration? right) -> bool +static AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration.operator ==(AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration? left, AgentCore.Application.Configuration.Schema.VoiceFillerConfiguration? right) -> bool +AgentCore.Application.Conversation.Conversations.DeleteMessageAsync(string! conversationId, string! messageId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Memory.InMemoryConversationStore.DeleteMessageAsync(string! conversationId, string! messageId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationStore.DeleteMessageAsync(string! conversationId, string! messageId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Conversations.TryMarkBusyAsync(string! conversationId, string! holder, System.TimeSpan lease, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Conversations.ClearBusyAsync(string! conversationId, string! holder, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Memory.InMemoryConversationStore.TryMarkBusyAsync(string! conversationId, string! holder, System.TimeSpan lease, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Conversation.Memory.InMemoryConversationStore.ClearBusyAsync(string! conversationId, string! holder, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationStore.TryMarkBusyAsync(string! conversationId, string! holder, System.TimeSpan lease, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Application.Ports.IConversationStore.ClearBusyAsync(string! conversationId, string! holder, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/AgentCore.Application/Runtime/AgentCoreAgent.cs b/src/AgentCore.Application/Runtime/AgentCoreAgent.cs index 1fbc2e8b..77519b8a 100644 --- a/src/AgentCore.Application/Runtime/AgentCoreAgent.cs +++ b/src/AgentCore.Application/Runtime/AgentCoreAgent.cs @@ -1,5 +1,6 @@ using AgentCore.Application.Conversation; using AgentCore.Application.Ports; +using AgentCore.Application.Runtime.Turn; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Agents.AI; @@ -11,55 +12,19 @@ namespace AgentCore.Application.Runtime /// /// The whole turn loop of this library, behind the framework's own agent abstraction. /// - /// - /// - /// is the turn loop — moderation, the stage machine, the writers, the audit - /// chain, barge-in — and its seam is , which only this library's - /// own adapters read. This shim puts the same loop behind , the seam the wider - /// ecosystem reads: anything that drives an AIAgent — the framework's protocol hosts (A2A, - /// AG-UI, the OpenAI-compatible endpoints, all prerelease as of Microsoft.Agents.AI 1.17.0), - /// AsAIFunction(), an evaluation harness — can drive a conversation without learning one AgentCore - /// type beyond this one. - /// - /// - /// One is one conversation. - /// starts the conversation, and every run with that session is one turn of it. The session wraps the - /// , and a host that needs the parts of the loop the AIAgent surface - /// does not carry — , - /// , the stage, - /// the transcript — asks the session for it: session.GetService<ConversationSession>(). - /// - /// - /// The session owns the transcript, so a run takes one user message and not a history. The - /// run reads the text of the LAST user message and ignores everything in front of it, which is the - /// same rule the /v1/responses endpoint applies to its input: an earlier message - /// of the conversation is already in the session. A host must not replay history here — replaying it would - /// put every turn in the transcript twice. - /// - /// - /// is accepted and ignored. The compiled document owns the model, the - /// tools, and the response shape, and the framework's contract lets an implementation ignore options - /// it cannot honor. - /// - /// - /// This agent is a process singleton, like the compiled agent it runs: everything per conversation lives in - /// the session. It holds no lock and no state of its own, so 26 concurrent conversations are 26 sessions - /// over one instance. - /// - /// public sealed class AgentCoreAgent : AIAgent { - private readonly IConversationSessionFactory _sessions; + private readonly IConversationSessions _sessions; private readonly string? _description; /// Creates the shim over one compiled entry's turn loop. - /// The factory that starts one for each conversation. + /// The owner that holds this entry's conversations live, shared by every entry. /// The entry this agent serves. Reported as . /// The description the agent reports, or . /// is . /// is null or empty. - public AgentCoreAgent(IConversationSessionFactory sessions, string entryName, string? description = null) + public AgentCoreAgent(IConversationSessions sessions, string entryName, string? description = null) { ArgumentNullException.ThrowIfNull(sessions); ArgumentException.ThrowIfNullOrEmpty(entryName); @@ -78,104 +43,55 @@ public AgentCoreAgent(IConversationSessionFactory sessions, string entryName, st /// public override string? Description => _description; - /// Starts one conversation under the id the host names. + /// Starts, or resumes, one conversation under the id the host names. /// The id the host gives the conversation. The vendor's conversation id belongs here. - /// The session of the new conversation, with no turn run yet. - /// - /// makes an id up, exactly as - /// does when it is handed none. A voice host has the - /// vendor's conversation id before the first turn, and the audit chain of the conversation should carry it, so - /// this overload exists for the same reason ChatClientAgent.CreateSessionAsync(string) - /// does. - /// + /// Cancels the open. + /// The live session of the conversation: the one this entry already holds, or a freshly opened one. /// is null or empty. - public ValueTask CreateSessionAsync(string conversationId) + /// Another entry holds this id live. + public async ValueTask CreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(conversationId); - return new(new AgentCoreAgentSession(_sessions.Create(conversationId))); + + ConversationSession conversation = await _sessions + .GetOrOpenAsync(EntryName, conversationId, state: null, cancellationToken) + .ConfigureAwait(false); + + return new AgentCoreAgentSession(conversation); } /// - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + protected override async ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) { - return new(new AgentCoreAgentSession(_sessions.Create())); + ConversationSession conversation = await _sessions + .GetOrOpenAsync(EntryName, conversationId: null, state: null, cancellationToken) + .ConfigureAwait(false); + + return new AgentCoreAgentSession(conversation); } /// - /// - /// - /// What travels is the conversation's id and what its session alone holds: the stage, whether the - /// machine finished, and the slots the writers filled. That is a - /// under the id it belongs to — the same pairing ChatClientAgentSession writes as - /// { conversationId, stateBag }, and for the same reason. State that travelled without - /// its id would come back on a conversation that has none of its words behind it. - /// - /// - /// The words are not here. They are store 1, which is durable already, so a checkpoint - /// that carried them would give one conversation two records and one chance to disagree. The - /// price is that this is a key and not a copy: a host moving a session between processes still - /// needs the shared store behind it, or the revived conversation answers with an empty transcript. - /// - /// - /// This blob is not store 0's. A host's reach - /// the nested state member too, not the envelope alone, so that member is encoded to the - /// host's rules and not to , which every store agrees on. It - /// is harmless while the member stays inside the envelope, because this seam and its reader are - /// symmetric. Lifting it out and writing it to store 0 is what would not be. - /// - /// - protected override ValueTask SerializeSessionCoreAsync( + protected override async ValueTask SerializeSessionCoreAsync( AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) { - // Refused, where a run answers a null session by making one. The fabricated conversation is the - // framework's one-shot shape for a RUN — one turn of a conversation nothing continues — and there is - // no such thing as a one-shot serialize: what it would write is an envelope naming a fresh - // random id beside an empty state, and a host would keep that as its checkpoint and never - // learn it points at nothing. The parameter is not nullable, so this only catches a caller - // that went around the type. ArgumentNullException.ThrowIfNull(session); - ConversationSession conversation = Resolve(session); + ConversationSession conversation = ResolveOwn(session).Conversation; - return new(JsonSerializer.SerializeToElement( - new SerializedSession(conversation.ConversationId, conversation.Snapshot(), EntryName), - jsonSerializerOptions ?? ConversationStateJson.Options)); + // A session this entry no longer holds live was unloaded: store 0's own copy of its state + // outranks whatever this blob would otherwise carry, so the state travels as null. + ConversationSession? live = await _sessions + .TryGetAsync(EntryName, conversation.ConversationId, cancellationToken) + .ConfigureAwait(false); + + return JsonSerializer.SerializeToElement( + new SerializedSession(conversation.ConversationId, live?.Snapshot(), EntryName), + jsonSerializerOptions ?? ConversationStateJson.Options); } /// - /// - /// - /// Reads back what wrote — see its remarks for what the - /// blob does and does not carry. It is host-facing, so what arrives is not necessarily anything - /// this library wrote, and it stays lenient about that: a blob that names no conversation gets a new id, - /// exactly as does, and state the - /// document no longer allows is dropped slot by slot with a diagnostic rather than refusing the - /// conversation. That second contract is 's and this method does not - /// double it. - /// - /// - /// The one blob it refuses carries neither member. That is not a validation layer over - /// but a guard against one specific confusion this library - /// creates itself: a bare is the literal value in store 0's - /// conversation.state column, so a host reaching for "the state of this conversation" reaches for the - /// wrong one of two shapes. Read as an envelope it names no conversation and holds no state, and the - /// failure is silent and total — a new random id, and the transcript orphaned behind it. - /// can never write that blob, so refusing it costs the - /// lenient path nothing. - /// - /// - /// Store 0 outranks what arrives here. The state travels with the conversation's id, and the - /// first turn of the revived conversation reads store 0 under that id; where store 0 holds state of its - /// own it wins outright, because its blob rides the same batch as the words. So this decides - /// only a conversation store 0 does not know, or knows without state. See - /// . - /// - /// - /// - /// names no conversation and holds no state. - /// - protected override ValueTask DeserializeSessionCoreAsync( + protected override async ValueTask DeserializeSessionCoreAsync( JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) @@ -193,13 +109,11 @@ protected override ValueTask DeserializeSessionCoreAsync( nameof(serializedState)); } - if (stored.Entry is { Length: > 0 } entry && !string.Equals(entry, EntryName, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"The session names entry '{stored.Entry}' and this agent serves entry '{EntryName}'."); - } + ConversationSession conversation = await _sessions + .GetOrOpenAsync(EntryName, stored.ConversationId, stored.State, cancellationToken) + .ConfigureAwait(false); - return new(new AgentCoreAgentSession(_sessions.Create(stored.ConversationId, stored.State))); + return new AgentCoreAgentSession(conversation); } /// @@ -211,8 +125,11 @@ protected override async Task RunCoreAsync( { ArgumentNullException.ThrowIfNull(messages); - ConversationSession conversation = Resolve(session); - TurnResult turn = await conversation.RunTurnMessageAsync(UserMessage(messages), cancellationToken).ConfigureAwait(false); + AgentCoreAgentSession own = session is null + ? (AgentCoreAgentSession)await CreateSessionAsync(cancellationToken).ConfigureAwait(false) + : await ResolveLiveAsync(session, cancellationToken).ConfigureAwait(false); + + TurnResult turn = await own.Conversation.RunTurnMessageAsync(UserMessage(messages), cancellationToken).ConfigureAwait(false); return new AgentResponse(new ChatMessage(ChatRole.Assistant, turn.ReplyText)) { @@ -234,40 +151,79 @@ protected override IAsyncEnumerable RunCoreStreamingAsync( return RunCoreStreamingCoreAsync(messages, session, cancellationToken); } - /// Streams one turn of the resolved conversation, wrapping each update with this agent's id. + /// Streams one turn, wrapping each update with this agent's id. private async IAsyncEnumerable RunCoreStreamingCoreAsync( IEnumerable messages, AgentSession? session, [EnumeratorCancellation] CancellationToken cancellationToken) { - ConversationSession conversation = Resolve(session); + AgentCoreAgentSession own = session is null + ? (AgentCoreAgentSession)await CreateSessionAsync(cancellationToken).ConfigureAwait(false) + : await ResolveLiveAsync(session, cancellationToken).ConfigureAwait(false); + + IAsyncEnumerable turn = own.Conversation.RunTurnMessageStreamingAsync(UserMessage(messages), cancellationToken); - // The stream is already filtered: ConversationSession drops the lifecycle updates, so every update - // here carries content. The wrap changes the type and nothing else. - await foreach (ChatResponseUpdate? update in conversation.RunTurnMessageStreamingAsync(UserMessage(messages), cancellationToken) - .ConfigureAwait(false)) + await foreach (ChatResponseUpdate? update in turn.ConfigureAwait(false)) { + if (update.Contents.OfType().Any()) + { + continue; + } + yield return new AgentResponseUpdate(update) { AgentId = Id }; } } + /// + /// Holds a session's live conversation for one host request, as + /// does. A conversation closed while the request waited is asked for again through the owner, and the request + /// waits on the new session instead, so it never runs on a disposed one. Pair it with one + /// on the conversation it returns. + /// + /// A session this agent created. + /// Cancels the waits. + /// The live conversation now held; the session's own GetService answers it too. + /// The conversation stayed busy past the wait limit. + internal async ValueTask EnterRequestAsync(AgentSession session, CancellationToken cancellationToken) + { + AgentCoreAgentSession own = ResolveOwn(session); + ConversationSession held = own.Conversation; + long started = held.Time.GetTimestamp(); + + while (true) + { + await held.Busy.EnterRequestAsync(started, cancellationToken).ConfigureAwait(false); + + ConversationSession live; + try + { + live = await own.ResolveAsync(_sessions, EntryName, cancellationToken).ConfigureAwait(false); + } + catch + { + await held.Busy.ExitRequestAsync().ConfigureAwait(false); + throw; + } + + if (ReferenceEquals(live, held)) + { + return held; + } + + await held.Busy.ExitRequestAsync().ConfigureAwait(false); + held = live; + } + } + /// Finds the conversation a run belongs to. - /// The session the caller passed, or . + /// The session the caller passed. /// The conversation to run the turn on. - /// - /// A session is the framework's one-shot shape — ChatClientAgent - /// answers it with a session it throws away — so it runs one turn of a conversation nothing can continue. - /// A session another agent created is refused, in the framework's own words: every - /// AIAgent implementation measured (ChatClientAgent, the workflow host) refuses a foreign - /// session rather than guessing at its state. - /// /// belongs to another agent type. - private ConversationSession Resolve(AgentSession? session) + private static AgentCoreAgentSession ResolveOwn(AgentSession session) { return session switch { - null => _sessions.Create(), - AgentCoreAgentSession own => own.Conversation, + AgentCoreAgentSession own => own, _ => throw new ArgumentException( $"Incompatible session type: {session.GetType()} (expecting {typeof(AgentCoreAgentSession)}). " + "Only a session this agent created can carry one of its conversations.", @@ -275,18 +231,26 @@ private ConversationSession Resolve(AgentSession? session) }; } + /// + /// Resolves a caller-supplied session's live conversation through the owner, so the run touches its + /// idle clock and a session that was unloaded since the caller last used it comes back rebuilt, with + /// its history intact, rather than running a turn on a disposed instance. + /// + private async ValueTask ResolveLiveAsync(AgentSession session, CancellationToken cancellationToken) + { + AgentCoreAgentSession own = ResolveOwn(session); + _ = await own.ResolveAsync(_sessions, EntryName, cancellationToken).ConfigureAwait(false); + return own; + } + /// Reads what the caller said or answered out of the run's messages. /// The messages the caller passed to the run. /// The last user message that carries words or an approval answer. - /// - /// The same rule the /v1/responses endpoint applies: the session owns the - /// transcript, so an earlier message of the conversation is already in the session, and only the last - /// user message is new. See the remarks on . - /// /// No user message carries words or an answer, so there is no turn to run. private static ChatMessage UserMessage(IEnumerable messages) { ChatMessage? picked = null; + foreach (ChatMessage message in messages) { if (message.Role == ChatRole.User @@ -303,48 +267,13 @@ private static ChatMessage UserMessage(IEnumerable messages) nameof(messages)); } - /// One serialized session: the conversation it is, the entry it belongs to, and the state it held. + /// One serialized session: the conversation it is, the entry that wrote it, and the state it held. /// The id of the conversation. Store 1 is keyed by it, so it is the half that finds the words. /// What the session alone held, or when the blob named none. - /// The entry that wrote the blob. A key minted by one entry never reads on another. - /// - /// A separate shape from on purpose. That one is the value store 0 - /// writes under a conversation_id column, so putting the id inside it would give one fact two - /// homes; here there is no column, so the envelope carries the key beside the value instead. - /// Internal because it is a wire shape and not a promise: D15 makes every public type permanent. - /// + /// + /// The entry that wrote the blob. Written on every serialize and never read back: any entry may resume a + /// conversation from a blob another entry wrote. + /// internal sealed record SerializedSession(string? ConversationId, ConversationSessionState? State, string? Entry); - - /// One conversation, as the framework sees it. - /// - /// It stays internal: a host reaches the call through GetService<ConversationSession>() on - /// the base, and D15 makes every public type a permanent promise this - /// wrapper does not need to be. - /// - internal sealed class AgentCoreAgentSession : AgentSession - { - internal AgentCoreAgentSession(ConversationSession conversation) - { - Conversation = conversation; - } - - /// Gets the conversation this session carries. - internal ConversationSession Conversation { get; } - - /// - /// - /// Answers the (and its - /// seam), so a host holding only the framework types reaches barge-in, the stage, and the - /// transcript without a cast. - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - - return serviceKey is null && serviceType.IsInstanceOfType(Conversation) - ? Conversation - : base.GetService(serviceType, serviceKey); - } - } } } diff --git a/src/AgentCore.Application/Runtime/AgentCoreAgentSession.cs b/src/AgentCore.Application/Runtime/AgentCoreAgentSession.cs new file mode 100644 index 00000000..9e2233b1 --- /dev/null +++ b/src/AgentCore.Application/Runtime/AgentCoreAgentSession.cs @@ -0,0 +1,43 @@ +using AgentCore.Application.Ports; +using Microsoft.Agents.AI; + +namespace AgentCore.Application.Runtime +{ + /// One conversation, as the framework sees it: the session creates. + internal sealed class AgentCoreAgentSession : AgentSession + { + internal AgentCoreAgentSession(ConversationSession conversation) + { + Conversation = conversation; + } + + /// Gets the live conversation this session carries, as of its last . + internal ConversationSession Conversation { get; private set; } + + /// + /// Asks the owner for this session's conversation again, under its own id. A live session comes back + /// touched; an unloaded one comes back rebuilt, with its history intact. Either way this session's + /// is updated to the one returned, so a later GetService reads the + /// current live session. + /// + internal async ValueTask ResolveAsync( + IConversationSessions sessions, string entry, CancellationToken cancellationToken) + { + Conversation = await sessions + .GetOrOpenAsync(entry, Conversation.ConversationId, state: null, cancellationToken) + .ConfigureAwait(false); + + return Conversation; + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + + return serviceKey is null && serviceType.IsInstanceOfType(Conversation) + ? Conversation + : base.GetService(serviceType, serviceKey); + } + } +} diff --git a/src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs b/src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs index 6c95dcf7..56e085c5 100644 --- a/src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs +++ b/src/AgentCore.Application/Runtime/AuditingFunctionInvokingChatClient.cs @@ -32,9 +32,6 @@ internal sealed record ToolFailure /// internal sealed class AuditingFunctionInvokingChatClient : FunctionInvokingChatClient { - // Drain state per open tool call, keyed by the model's own call id. The round that ends a - // turn never reaches CreateResponseMessages, so a final round's entries are swept at turn - // end through Release; every other entry is consumed where it drains. private readonly ConcurrentDictionary _drains = new(StringComparer.Ordinal); private sealed record Drain( @@ -80,11 +77,6 @@ internal void Release(IReadOnlyList callIds) string callId = context.CallContent.CallId; - // The turn arrives on the run's own options — filed there by the loop for the outer - // run and by the delegating bridge for a nested one, one value per run, so concurrent - // runs never share it. A call with no turn runs bare, exactly as the null branch did - // before. Nested runs arrive already stripped per K42 with the outermost call id kept, - // so nothing here recomputes either. TurnInvocation? invocation = null; bool nested = false; @@ -126,6 +118,8 @@ internal void Release(IReadOnlyList callIds) } catch (Exception failure) when (!IsCallerCancellation(failure, cancellationToken)) { + ToolFaultMark.Put(failure, callId); + invocation?.OnToolFailure?.Invoke(new ToolFailure { ToolName = context.CallContent.Name, diff --git a/src/AgentCore.Application/Runtime/ConversationBusyMark.cs b/src/AgentCore.Application/Runtime/ConversationBusyMark.cs new file mode 100644 index 00000000..c624f36c --- /dev/null +++ b/src/AgentCore.Application/Runtime/ConversationBusyMark.cs @@ -0,0 +1,331 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Diagnostics; + +namespace AgentCore.Application.Runtime +{ + /// + /// The conversation's busy mark in the store. + /// +#pragma warning disable CA1001 // _requests never hands out its wait handle, so it holds nothing to dispose. + internal sealed class ConversationBusyMark +#pragma warning restore CA1001 + { + /// + /// How long a mark lives after it was last put. A host that crashed frees the conversation this long after + /// its last renewal. + /// + internal static readonly TimeSpan Lease = TimeSpan.FromSeconds(10); + + /// + /// How long a turn waits for another session's mark, or a host request for this session's earlier request, before + /// it is refused. It covers a stopped turn's work after the reply and the filing after the abort, each bounded by + /// , and the of a holder that crashed. + /// + internal static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(15); + + private static readonly TimeSpan RenewEvery = Lease / 3; + + /// How long a waiting turn first waits before it asks for the mark again. Each wait doubles, up to 250 ms. + internal static readonly TimeSpan FirstPoll = TimeSpan.FromMilliseconds(50); + + private static readonly TimeSpan LastPoll = TimeSpan.FromMilliseconds(250); + + private readonly ConversationSession _session; + + private readonly string _holder = Guid.NewGuid().ToString("N"); + + private readonly Lock _gate = new(); + + // One host request at a time: requests of one live session share its holder, so the store mark alone lets them all in. + private readonly SemaphoreSlim _requests = new(1, 1); + + // Guarded by _gate. _holds counts the holders that entered and have not left, waiting ones included. + private int _holds; + + private bool _held; + + private ITimer? _renewal; + + private Task _writes = Task.CompletedTask; + + internal ConversationBusyMark(ConversationSession session) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + } + + /// + /// Holds the mark: takes it from the store unless this session already holds it, waiting up to + /// while another session's mark is live. Each call is paired with one , + /// made only once this returned. + /// + /// Cancels the wait. + /// Another session held the mark past the limit. + internal ValueTask EnterAsync(CancellationToken cancellationToken) + { + return HoldAsync(_session.Time.GetTimestamp(), cancellationToken); + } + + private async ValueTask HoldAsync(long started, CancellationToken cancellationToken) + { + lock (_gate) + { + _holds++; + if (_held) + { + return; + } + } + + // Two turns of one session that take at once both succeed: they mark under the same holder. + try + { + await TakeAsync(started, cancellationToken).ConfigureAwait(false); + + lock (_gate) + { + _held = true; + _renewal ??= _session.Time.CreateTimer(_ => Renew(), state: null, RenewEvery, RenewEvery); + } + } + catch + { + _ = Exit(); + throw; + } + } + + /// + /// Holds the mark for one host request: waits for this session's earlier request, if any, to leave, holds the + /// mark as does, then waits for a turn another door started on this session to end. All + /// the waits together last at most from . Each call is paired + /// with one , made only once this returned. + /// + /// The at which the request began to wait. + /// Cancels the wait. + /// An earlier request, a turn or another session held on past the limit. + internal async ValueTask EnterRequestAsync(long started, CancellationToken cancellationToken) + { + TimeSpan left = WaitLimit - _session.Time.GetElapsedTime(started); + + if (left <= TimeSpan.Zero) + { + throw Refuse(_session); + } + + using (CancellationTokenSource limit = new(left, _session.Time)) + using (CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, limit.Token)) + { + try + { + await _requests.WaitAsync(wait.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone); + throw; + } + catch (OperationCanceledException) + { + throw Refuse(_session); + } + } + + try + { + await HoldAsync(started, cancellationToken).ConfigureAwait(false); + } + catch + { + _ = _requests.Release(); + throw; + } + + try + { + await _session.Cuts.WaitForNoTurnAsync(started, cancellationToken).ConfigureAwait(false); + } + catch + { + await ExitRequestAsync().ConfigureAwait(false); + throw; + } + } + + /// Releases the hold of one host request, and lets the next request of this session in once the mark is clear. + /// The clear, as returns it. It never faults. + internal async Task ExitRequestAsync() + { + try + { + await Exit().ConfigureAwait(false); + } + finally + { + _ = _requests.Release(); + } + } + + /// Releases one hold. The last one stops the renewal and clears the mark in the store. + /// The clear, for a host that answers only once the conversation is free. It never faults. + internal Task Exit() + { + lock (_gate) + { + if (--_holds > 0) + { + return Task.CompletedTask; + } + + _held = false; + _renewal?.Dispose(); + _renewal = null; + } + + return Enqueue(ClearAsync); + } + + private async Task TakeAsync(long started, CancellationToken cancellationToken) + { + TimeSpan poll = FirstPoll; + + try + { + while (!await Enqueue(() => MarkAsync(cancellationToken)).ConfigureAwait(false)) + { + TimeSpan left = WaitLimit - _session.Time.GetElapsedTime(started); + if (left <= TimeSpan.Zero) + { + throw Refuse(_session); + } + + await Task.Delay(poll < left ? poll : left, _session.Time, cancellationToken).ConfigureAwait(false); + poll = poll * 2 < LastPoll ? poll * 2 : LastPoll; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone); + throw; + } + } + + /// Refuses a turn that waited for the conversation, and raises its busy refusal. + /// The conversation the turn waited for. + /// The exception to throw. + internal static ConversationTurnConflictException Refuse(ConversationSession session) + { + TurnRefusals.Raise(session, turnIndex: null, TurnRefusals.Busy); + return new ConversationTurnConflictException( + $"Another request still held conversation '{session.ConversationId}' after {WaitLimit.TotalSeconds:0} s, " + + "so this turn was refused before it ran."); + } + + private async Task MarkAsync(CancellationToken cancellationToken) + { + try + { + return await _session.Compiled.ConversationStore + .TryMarkBusyAsync(_session.ConversationId, _holder, Lease, cancellationToken) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // A store that cannot be marked never stops a turn: the store's turn check stays the backstop. + catch (Exception exception) when (exception is not OperationCanceledException) +#pragma warning restore CA1031 + { + Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception); + return true; + } + } + + private void Renew() + { + _ = Enqueue(async () => + { + lock (_gate) + { + if (!_held) + { + return false; + } + } + + try + { + if (!await _session.Compiled.ConversationStore + .TryMarkBusyAsync(_session.ConversationId, _holder, Lease, CancellationToken.None) + .ConfigureAwait(false)) + { + Log.BusyMarkLost(_session.Logger, _session.ConversationId); + } + } +#pragma warning disable CA1031 // A renewal that fails is retried by the next tick, and the mark lapses at worst. + catch (Exception exception) +#pragma warning restore CA1031 + { + Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception); + } + + return true; + }); + } + + private async Task ClearAsync() + { + lock (_gate) + { + // A turn entered since the release: its own mark takes this one over, so clearing would free a busy conversation. + if (_holds > 0) + { + return false; + } + } + + try + { + await _session.Compiled.ConversationStore + .ClearBusyAsync(_session.ConversationId, _holder, CancellationToken.None) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // A mark that cannot be cleared lapses after its lease. + catch (Exception exception) +#pragma warning restore CA1031 + { + Log.BusyMarkFailed(_session.Logger, _session.ConversationId, exception); + } + + return true; + } + + /// + /// Runs one store write of the mark after every write asked for before it. With none pending it runs at once, + /// on the caller's thread, so a store that answers synchronously starts a turn synchronously too. + /// + private Task Enqueue(Func> write) + { + TaskCompletionSource written = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task before; + + lock (_gate) + { + before = _writes; + _writes = written.Task; + } + + return AfterAsync(before, write, written); + } + + private static async Task AfterAsync(Task before, Func> write, TaskCompletionSource written) + { + try + { + await before.ConfigureAwait(false); + return await write().ConfigureAwait(false); + } + finally + { + written.SetResult(); + } + } + } +} diff --git a/src/AgentCore.Application/Runtime/ConversationCutTracker.cs b/src/AgentCore.Application/Runtime/ConversationCutTracker.cs new file mode 100644 index 00000000..68a98ee2 --- /dev/null +++ b/src/AgentCore.Application/Runtime/ConversationCutTracker.cs @@ -0,0 +1,302 @@ +namespace AgentCore.Application.Runtime +{ + internal sealed class ConversationCutTracker + { + private readonly ConversationSession _session; + + private bool _running; + + private TaskCompletionSource? _freed; + + private bool _closed; + + private TurnCutSlot? _slot; + + private int _slotTurnIndex; + + private int _newestStarted = -1; + + internal ConversationCutTracker(ConversationSession session) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + } + + /// Cuts one turn's reply by the Cut rule (design section 3). + /// The turn the user was seeing or hearing. + /// What reached the user. + /// + /// when the running turn took the cut, or the committed turn was rewritten to it; + /// for a turn older than the newest turn started, which is left as it is. + /// + internal bool Cut(int turnIndex, TurnCut cut) + { + ArgumentNullException.ThrowIfNull(cut); + if (cut.Played is { } played) + { + ArgumentOutOfRangeException.ThrowIfLessThan(played, TimeSpan.Zero, nameof(cut)); + } + + lock (_session.TurnLock) + { + if (turnIndex < _newestStarted) + { + return false; + } + + // A slot that refuses the cut has committed its turn, which is then the last turn. + if (_slot is { } slot && _slotTurnIndex == turnIndex && slot.TryCut(cut)) + { + return true; + } + + return AmendTurn(turnIndex, cut, recut: false); + } + } + + /// Replaces the cut one turn already took, while it is the newest turn started. + /// The turn the user was seeing or hearing. + /// A later account of what reached the user. + /// + /// when the running turn took the recut, or the committed turn was rewritten to it; + /// for a turn that took no cut, or one older than the newest turn started. + /// + internal bool Recut(int turnIndex, TurnCut cut) + { + ArgumentNullException.ThrowIfNull(cut); + if (cut.Played is { } played) + { + ArgumentOutOfRangeException.ThrowIfLessThan(played, TimeSpan.Zero, nameof(cut)); + } + + lock (_session.TurnLock) + { + if (turnIndex < _newestStarted) + { + return false; + } + + if (_slot is { } slot && _slotTurnIndex == turnIndex && slot.TryRecut(cut)) + { + return true; + } + + return AmendTurn(turnIndex, cut, recut: true); + } + } + + /// + /// Opens the window in which reaches this turn. + /// + /// The index of the turn about to run. + /// The token of the host. + /// The slot whose token the run reads. The caller ends it with . + internal TurnCutSlot StartRun(int turnIndex, CancellationToken cancellationToken) + { + TurnCutSlot slot = new(_session.TurnLock, cancellationToken); + + lock (_session.TurnLock) + { + _slot = slot; + _slotTurnIndex = turnIndex; + _newestStarted = Math.Max(_newestStarted, turnIndex); + } + + return slot; + } + + /// + /// Takes the turn slot, waiting for a running turn of this conversation to free it, up to + /// counted from . + /// + /// The at which the wait for the conversation began. + /// Cancels the wait. + /// The conversation was disposed. + /// Another turn still ran past the limit. + internal Task EnterTurnAsync(long started, CancellationToken cancellationToken) + { + return WaitForSlotAsync(take: true, started, cancellationToken); + } + + /// + /// Waits, as does, until no turn holds the slot, without taking it. + /// + /// The at which the wait for the conversation began. + /// Cancels the wait. + /// Another turn still ran past the limit. + internal Task WaitForNoTurnAsync(long started, CancellationToken cancellationToken) + { + return WaitForSlotAsync(take: false, started, cancellationToken); + } + + private async Task WaitForSlotAsync(bool take, long started, CancellationToken cancellationToken) + { + while (true) + { + if (take && TryEnterTurn()) + { + return; + } + + Task freed; + lock (_session.TurnLock) + { + if (!_running) + { + // The turn ended since the take above failed: take again, or refuse a disposed conversation. + if (take) + { + continue; + } + + return; + } + + freed = FreedLocked(); + } + + TimeSpan left = ConversationBusyMark.WaitLimit - _session.Time.GetElapsedTime(started); + try + { + if (left <= TimeSpan.Zero) + { + throw new TimeoutException(); + } + + await freed.WaitAsync(left, _session.Time, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + throw ConversationBusyMark.Refuse(_session); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TurnRefusals.Raise(_session, turnIndex: null, TurnRefusals.Gone); + throw; + } + } + } + + /// Takes the turn slot, unless a turn of this conversation is still running. + /// when another turn holds the slot. + /// The conversation was disposed. + internal bool TryEnterTurn() + { + lock (_session.TurnLock) + { + if (_closed) + { + throw new ObjectDisposedException( + nameof(ConversationSession), $"The conversation '{_session.ConversationId}' was disposed, so it runs no further turn."); + } + + if (_running) + { + return false; + } + + _running = true; + return true; + } + } + + /// + /// Refuses every later turn, and waits for the one that holds the slot, if any, to free it: a turn admitted + /// before may still swap the session in its catch-up and start children on it. + /// + /// Completes once no turn holds the slot. A run the caller started and never read nor disposed holds it for good. + internal Task CloseTurnsAsync() + { + lock (_session.TurnLock) + { + _closed = true; + return _running ? FreedLocked() : Task.CompletedTask; + } + } + + /// Reads whether a turn holds the slot, without refusing any later turn. + /// What completes once the running turn frees the slot, or when no turn holds it. + internal Task? RunningTurn() + { + lock (_session.TurnLock) + { + return _running ? FreedLocked() : null; + } + } + + private Task FreedLocked() + { + return (_freed ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + + /// Frees the turn slot after a turn refused to start left it taken. + internal void ReleaseTurn() + { + TaskCompletionSource? freed; + lock (_session.TurnLock) + { + _running = false; + freed = _freed; + _freed = null; + } + + _ = freed?.TrySetResult(); + } + + /// + /// Closes the window, and frees the session for the next turn. + /// + /// The slot returned. + internal void EndRun(TurnCutSlot slot) + { + // The field drops first. A cut that arrives now meets no slot to cancel, so it reaches + // the amendment path rather than a disposed one. + lock (_session.TurnLock) + { + _slot = null; + } + + slot.Dispose(); + ReleaseTurn(); + } + + /// + /// Rewrites the reply of the turn that committed last to what the user saw or heard. Runs under the turn lock. + /// + /// The turn the cut names. Only the last committed turn is amended. + /// What reached the user. + /// Whether the cut replaces one the turn already took, rather than cutting it for the first time. + /// + /// when the turn was amended, and when the turn is not the last + /// one, was already cut (or, for a recut, never was), or wrote no reply. + /// + internal bool AmendTurn(int turnIndex, TurnCut cut, bool recut) + { + // The chain has already closed, so nothing may be appended behind conversation.ended. + if (_session.Events.HasEnded + || _session.AmendableEventId is not { } completedEventId + || _session.LastTurn is not { } finished + || (finished.Cut is not null) != recut + || finished.TurnIndex != turnIndex + || _session.Ledger.Session() is not { } session) + { + return false; + } + + string shown = (cut.ShownText ?? _session.AmendableText ?? finished.ReplyText).Trim(); + + if (!_session.History.RewriteReply(session, turnIndex, shown)) + { + return false; + } + + _session.LastTurn = finished with { ReplyText = shown, Cut = cut.Played ?? TimeSpan.Zero }; + _session.AmendableText = shown; + + _session.Events.RaiseReplyInterrupted( + finished.TurnIndex, _session.Time.GetUtcNow(), completedEventId, shown, cut.Played); + + return true; + } + } +} diff --git a/src/AgentCore.Application/Runtime/ConversationEventChain.cs b/src/AgentCore.Application/Runtime/ConversationEventChain.cs index 8ae80277..47b51cc2 100644 --- a/src/AgentCore.Application/Runtime/ConversationEventChain.cs +++ b/src/AgentCore.Application/Runtime/ConversationEventChain.cs @@ -89,13 +89,15 @@ internal bool EndConversation(ConversationEndReason reason, DateTimeOffset ended /// Raises the durable facts of one finished turn, in the order they happened. /// The turn that just spoke: its index, stages, the text the caller heard, when it ended, and how far it played. - /// The whole reply the model produced. + /// The words turn.completed proves. + /// The words a cut turn's reply.interrupted proves. /// The message of the fault, or . + /// How much of a cut reply played, or when no voice layer knows it. /// /// The identity of the turn.completed fact, so a barge-in that arrives after this turn /// already ended can name it through . /// - internal Guid WriteTurnEvents(TurnResult result, string spokenReply, string? toolFault) + internal Guid WriteTurnEvents(TurnResult result, string generatedText, string heard, string? toolFault, TimeSpan? played) { (int turnIndex, DateTimeOffset endedAt) = (result.TurnIndex, result.EndedAt); @@ -117,14 +119,14 @@ internal Guid WriteTurnEvents(TurnResult result, string spokenReply, string? too turnIndex, payload: new Dictionary(StringComparer.Ordinal) { - [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(spokenReply).Value, + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(generatedText).Value, [AuditPayloadKeys.StageBefore] = result.StageBefore, [AuditPayloadKeys.StageAfter] = result.StageAfter, }); - if (result.InterruptedAfter is { } played) + if (result.Cut is not null) { - RaiseReplyInterrupted(turnIndex, endedAt, completed, result.ReplyText, played); + RaiseReplyInterrupted(turnIndex, endedAt, completed, heard, played); } return completed; @@ -135,25 +137,29 @@ internal Guid WriteTurnEvents(TurnResult result, string spokenReply, string? too /// When the cut was recorded. /// The identity of the turn.completed fact it corrects. /// The text the caller actually heard. - /// How much of the reply played, as the relay reported it. + /// + /// How much of the reply played, as the relay reported it, or when nothing played it: + /// the payload then omits the duration (design section 7, item 5). + /// internal void RaiseReplyInterrupted( int turnIndex, DateTimeOffset occurredAt, Guid amendsEventId, string heard, - TimeSpan played) + TimeSpan? played) { - _ = Raise( - ConversationEventKind.ReplyInterrupted, - occurredAt, - turnIndex, - amends: amendsEventId, - payload: new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(heard).Value, - [AuditPayloadKeys.DurationUntilInterruptMs] = - ((long)played.TotalMilliseconds).ToString(CultureInfo.InvariantCulture), - }); + Dictionary payload = new(StringComparer.Ordinal) + { + [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(heard).Value, + }; + + if (played is { } duration) + { + payload[AuditPayloadKeys.DurationUntilInterruptMs] = + ((long)duration.TotalMilliseconds).ToString(CultureInfo.InvariantCulture); + } + + _ = Raise(ConversationEventKind.ReplyInterrupted, occurredAt, turnIndex, amends: amendsEventId, payload: payload); } /// Raises the moderation facts of one turn, before the turn's own events. diff --git a/src/AgentCore.Application/Runtime/ConversationEventKind.cs b/src/AgentCore.Application/Runtime/ConversationEventKind.cs index 98a997a3..5248267c 100644 --- a/src/AgentCore.Application/Runtime/ConversationEventKind.cs +++ b/src/AgentCore.Application/Runtime/ConversationEventKind.cs @@ -49,5 +49,14 @@ public enum ConversationEventKind /// held. A message appended outside a turn since the last read is not among them. /// TranscriptResyncFailed = 13, + + /// A turn was refused or dropped, so none of its words were kept. + TurnRefused = 14, + + /// + /// The turn's run faulted outside every tool, say because the model endpoint did not answer, so the turn spoke + /// the fallback. A tool that spent its retry budget is instead. + /// + RunFaulted = 15, } } diff --git a/src/AgentCore.Application/Runtime/ConversationEventKinds.cs b/src/AgentCore.Application/Runtime/ConversationEventKinds.cs index b971c28d..9ae0dce4 100644 --- a/src/AgentCore.Application/Runtime/ConversationEventKinds.cs +++ b/src/AgentCore.Application/Runtime/ConversationEventKinds.cs @@ -8,7 +8,7 @@ namespace AgentCore.Application.Runtime /// internal static class ConversationEventKinds { - /// The seven kinds the chain stores, and the audit kind each one is stored as. + /// The eight kinds the chain stores, and the audit kind each one is stored as. private static readonly FrozenDictionary AuditKinds = new Dictionary { @@ -19,6 +19,7 @@ internal static class ConversationEventKinds [ConversationEventKind.ReplyInterrupted] = AuditEventKind.ReplyInterrupted, [ConversationEventKind.ConversationEnded] = AuditEventKind.ConversationEnded, [ConversationEventKind.TurnSuperseded] = AuditEventKind.TurnSuperseded, + [ConversationEventKind.TurnRefused] = AuditEventKind.TurnRefused, }.ToFrozenDictionary(); /// @@ -43,6 +44,7 @@ public static string ToToken(ConversationEventKind kind) ConversationEventKind.ReplyInterrupted => AuditEventKinds.ToToken(AuditEventKind.ReplyInterrupted), ConversationEventKind.ConversationEnded => AuditEventKinds.ToToken(AuditEventKind.ConversationEnded), ConversationEventKind.TurnSuperseded => AuditEventKinds.ToToken(AuditEventKind.TurnSuperseded), + ConversationEventKind.TurnRefused => AuditEventKinds.ToToken(AuditEventKind.TurnRefused), ConversationEventKind.ModerationUnavailable => "moderation.unavailable", ConversationEventKind.ModerationClean => "moderation.clean", ConversationEventKind.EmptyReply => "reply.empty", @@ -50,6 +52,7 @@ public static string ToToken(ConversationEventKind kind) ConversationEventKind.TranscriptWriteFailed => "transcript.write.failed", ConversationEventKind.StateRestorePartial => "state.restore.partial", ConversationEventKind.TranscriptResyncFailed => "transcript.resync.failed", + ConversationEventKind.RunFaulted => "run.faulted", // A kind outside the closed set must not cost the report the fault it is carrying, so this // names the value instead of throwing over it. diff --git a/src/AgentCore.Application/Runtime/ConversationInterruptionTracker.cs b/src/AgentCore.Application/Runtime/ConversationInterruptionTracker.cs deleted file mode 100644 index 8850de72..00000000 --- a/src/AgentCore.Application/Runtime/ConversationInterruptionTracker.cs +++ /dev/null @@ -1,179 +0,0 @@ -namespace AgentCore.Application.Runtime -{ - internal sealed class ConversationInterruptionTracker - { - private readonly ConversationSession _session; - - // One session runs one turn at a time. Lives here, with the rest of the live run, so the - // guard and the release travel together; BeginTurn enters through TryEnterTurn. - private int _running; - - // Whether the running turn has already handed the host something to speak. One rule for both - // run shapes: a run that has handed the host nothing cannot be the turn the caller was hearing. - private volatile bool _runIsAudible; - - /// - /// Whether the running turn has handed the host something to speak. - /// - internal bool RunIsAudible { get => _runIsAudible; set => _runIsAudible = value; } - - internal ConversationInterruptionTracker(ConversationSession session) - { - ArgumentNullException.ThrowIfNull(session); - _session = session; - } - - /// - /// Ends the running turn where the caller cut the reply off. - /// - /// The text the caller actually heard. - /// How much of the reply played, as the relay reported it. - /// - /// Whether the turn running now is the turn the caller was hearing. An adapter that tracks which - /// turn's output actually reached the vendor answers that question itself and says so here; - /// sends the frame straight to the amendment path, whatever the running - /// turn has produced. The default is , which leaves the decision to this - /// session alone, and that is the right answer for a caller that speaks over a reply it is - /// itself streaming. - /// - /// - /// when this conversation recorded the barge-in, either by ending the running - /// turn or by amending the turn that finished last, and when there was - /// nothing to record it against. - /// - /// - /// is negative. - /// - internal bool Interrupt( - string utteranceUntilInterrupt, - TimeSpan durationUntilInterrupt, - bool cutsRunningTurn = true) - { - ArgumentNullException.ThrowIfNull(utteranceUntilInterrupt); - ArgumentOutOfRangeException.ThrowIfLessThan(durationUntilInterrupt, TimeSpan.Zero); - - lock (_session.InterruptLock) - { - // Both conditions, and in this order. The adapter's answer only ever removes the - // in-flight path: an adapter that knows the caller was hearing an earlier turn skips it - // outright, and one that says nothing leaves this session's own audibility test to - // decide. - if (cutsRunningTurn && _session.RunCancellation is { } cancellation && RunIsAudible) - { - _session.Interruption = new Interruption(utteranceUntilInterrupt, durationUntilInterrupt); - cancellation.Cancel(); - return true; - } - - return AmendLastTurn(new Interruption(utteranceUntilInterrupt, durationUntilInterrupt)); - } - } - - /// - /// Opens the window in which reaches this turn. - /// - /// The token of the host. - /// The source the run reads. The caller ends it with . - internal CancellationTokenSource StartRun(CancellationToken cancellationToken) - { - CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - lock (_session.InterruptLock) - { - _session.Interruption = null; - _session.RunCancellation = cancellation; - _runIsAudible = false; - } - - return cancellation; - } - - /// Takes the turn slot, unless a turn of this conversation is still running. - /// when another turn holds the slot. - internal bool TryEnterTurn() - { - return Interlocked.Exchange(ref _running, 1) == 0; - } - - /// Frees the turn slot after a turn refused to start left it taken. - internal void ReleaseTurn() - { - Volatile.Write(ref _running, 0); - } - - /// - /// Closes the window, and frees the session for the next turn. - /// - /// The source returned. - internal void EndRun(CancellationTokenSource cancellation) - { - // The field drops first. A frame that arrives now meets no source to cancel, so it reaches - // the amendment path rather than a disposed one. - lock (_session.InterruptLock) - { - _session.RunCancellation = null; - _runIsAudible = false; - } - - cancellation.Dispose(); - Volatile.Write(ref _running, 0); - } - - /// - /// Records a barge-in against the turn that finished last, and corrects its record. - /// - /// What the relay reported: the heard text and the played duration. - /// - /// when the turn was amended, and when there was - /// no turn to amend. - /// - internal bool AmendLastTurn(Interruption cut) - { - // The chain has already closed, so nothing may be appended behind conversation.ended. - if (_session.Events.HasEnded - || _session.AmendableEventId is not { } completedEventId - || _session.LastTurn is not { InterruptedAfter: null } finished - || _session.Ledger.Session() is not { } session) - { - return false; - } - - string heard = cut.HeardText.Trim(); - - // Store 1 keeps the reply row and rewrites its words, rather than replacing the turn's - // messages. The turn it corrects ran to its end, so every tool call it made is already - // paired and there is nothing unfinished to strip out. - // - // The cut always lands today: a turn is amendable only when it completed with words, and a - // turn that completed with words left a reply row holding them. The answer is still read, - // because the row below names a HASH of those words — a chain that proves words store 1 - // does not hold proves nothing, so no cut means no amendment. - if (!_session.History.TruncateLastReply(session, heard, cut.PlayedDuration)) - { - return false; - } - - _session.LastTurn = finished with { ReplyText = heard, InterruptedAfter = cut.PlayedDuration }; - - _session.Events.RaiseReplyInterrupted( - finished.TurnIndex, _session.Time.GetUtcNow(), completedEventId, heard, cut.PlayedDuration); - - return true; - } - - /// Reads the interruption the relay reported for the running turn. - /// The record, or when the caller did not interrupt. - internal Interruption? CurrentInterruption() - { - lock (_session.InterruptLock) - { - return _session.Interruption; - } - } - - /// What the relay reported when the caller spoke over the reply. - /// The text the caller actually heard. - /// How much of the reply played, at 1 ms. - internal sealed record Interruption(string HeardText, TimeSpan PlayedDuration); - } -} diff --git a/src/AgentCore.Application/Runtime/ConversationSession.cs b/src/AgentCore.Application/Runtime/ConversationSession.cs index 8e5859fb..325f6490 100644 --- a/src/AgentCore.Application/Runtime/ConversationSession.cs +++ b/src/AgentCore.Application/Runtime/ConversationSession.cs @@ -1,16 +1,16 @@ using System.Text.Json; -using AgentCore.Application.Conversation; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; using AgentCore.Application.Policy; using AgentCore.Application.Ports; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.State; using AgentCore.Application.Transcript; using AgentCore.Domain; -using AgentCore.Domain.Knowledge; using AgentCore.Domain.Audit; +using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -38,6 +38,13 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable /// public const string ToolFailureReason = "a tool failed four times in a row, so the turn spoke the fallback."; + /// + /// The reason a turn whose run faulted outside every tool reports, before the message of the fault: a model + /// endpoint that did not answer, or a graph that matched no edge. No tool let the fault out, so this is never + /// 's wording. + /// + public const string RunFaultReason = "the turn's run faulted, so it spoke the fallback."; + /// /// The failure the turn records when the completion work passes its deadline. /// @@ -58,9 +65,6 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable /// internal static readonly TimeSpan TurnCompletionTimeout = TimeSpan.FromSeconds(5); - // Phase 1 seam: one live-state owner, verb-split operators. The helpers below hold this - // session and operate on the same fields under the same lock; only ownership of the verbs - // moved, so every commit protocol is byte-identical to the single-file shape. internal StagePolicy? Policy { get; } internal StateExtractor? Extractor { get; } @@ -79,7 +83,7 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal ILogger Logger { get; } - internal Lock InterruptLock { get; } = new(); + internal Lock TurnLock { get; } = new(); internal ConversationShells? Shells { get; } @@ -100,12 +104,11 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable /// internal JsonElement? GraphBlob { get; set; } - internal CancellationTokenSource? RunCancellation { get; set; } - - internal ConversationInterruptionTracker.Interruption? Interruption { get; set; } - internal Guid? AmendableEventId { get; set; } + /// The words the rows of the turn names say now. + internal string? AmendableText { get; set; } + internal ConversationSessionState? Checkpoint { get; set; } internal Clarifications Clarifications { get; } = new(); @@ -114,11 +117,9 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal ConversationTurnStream Stream { get; } - internal ConversationTurnCompletion Completion { get; } - internal ConversationTurnWriters Writers { get; } - internal ConversationInterruptionTracker Interruptions { get; } + internal ConversationCutTracker Cuts { get; } internal ConversationTranscriptLedger Ledger { get; } @@ -126,6 +127,8 @@ public sealed class ConversationSession : IConversationPort, IAsyncDisposable internal ConversationSessionLifetime Lifetime { get; } + internal ConversationBusyMark Busy { get; } + /// /// Creates the session of one conversation. /// @@ -160,43 +163,33 @@ internal ConversationSession( Time = timeProvider; - // The seam is optional and it has a working default. A host that binds nothing to watch the - // conversation still answers it, and the library never throws for want of an observer. Events = new ConversationEventChain(conversationId, observers ?? new ConversationObserverDispatcher([]), timeProvider); StartedAt = timeProvider.GetUtcNow(); - // An entry with no policy: has no stage machine. The single-agent row and both graph rows - // read that way, and neither of them ever ends a conversation by itself. Policy = compiled.Policy is null ? null : compiled.CreatePolicy(guards); State = new StateDocument(compiled.Configuration, Policy?.Stage); - // The writers run in a fixed order, and this is its only record: const slots land before - // any turn, then each turn applies tool results, the extractor, the clock fields and the - // counters, in CompleteTurnAsync. Guards read the finished document, so the order is - // load-bearing. _ = ConstStateWriter.Apply(State); - // The first fact of this SESSION, and not necessarily of the conversation: a resumed conversation raises a - // second one, behind the turns of the session before it. Nothing allocates a position here - // any more — store 3 assigns the sequence — so a second one collides with nothing, and it is - // raised rather than suppressed because a session picking the conversation up is a fact that - // happened and the chain is where facts that happened go. Suppressing it is also not - // available from here: knowing whether the conversation already has words needs the store read that - // only OpenSessionAsync can do, and moving the raise there would let a conversation opened, never - // spoken to, and then ended through EndConversation write a conversation.ended with no conversation.started in front - // of it. _ = Events.Raise(ConversationEventKind.ConversationStarted, StartedAt, turnIndex: null); Runner = new ConversationTurnRunner(this); + Stream = new ConversationTurnStream(this); - Completion = new ConversationTurnCompletion(this); + Writers = new ConversationTurnWriters(this); - Interruptions = new ConversationInterruptionTracker(this); + + Cuts = new ConversationCutTracker(this); + Ledger = new ConversationTranscriptLedger(this); + States = new ConversationSessionStateStore(this); + Lifetime = new ConversationSessionLifetime(this); + + Busy = new ConversationBusyMark(this); } /// @@ -291,7 +284,7 @@ public Task RunTurnAtOriginAsync( { ArgumentNullException.ThrowIfNull(userInput); - return Runner.RunTurnCoreAsync(new ChatMessage(ChatRole.User, userInput), origin, cancellationToken); + return Stream.RunTurnCoreAsync(new ChatMessage(ChatRole.User, userInput), origin, cancellationToken); } /// @@ -303,13 +296,13 @@ public Task RunTurnMessageAtOriginAsync( { ArgumentNullException.ThrowIfNull(userInput); - return Runner.RunTurnCoreAsync(userInput, origin, cancellationToken); + return Stream.RunTurnCoreAsync(userInput, origin, cancellationToken); } /// Runs one turn and streams the reply as it arrives. /// The reply, one update at a time. Every update carries content. /// - /// The conversation already reached a terminal stage, another turn of this conversation is still running, or the + /// The conversation already reached a terminal stage, another turn of this conversation still ran after the wait limit, or the /// stage the machine holds names no agent. /// public IAsyncEnumerable RunTurnStreamingAsync( @@ -321,7 +314,7 @@ public IAsyncEnumerable RunTurnStreamingAsync( /// Runs one turn from a message the caller built and streams the reply as it arrives. /// The reply, one update at a time. Every update carries content. /// - /// The conversation already reached a terminal stage, another turn of this conversation is still running, or the + /// The conversation already reached a terminal stage, another turn of this conversation still ran after the wait limit, or the /// stage the machine holds names no agent. /// public IAsyncEnumerable RunTurnMessageStreamingAsync( @@ -376,15 +369,23 @@ public IAsyncEnumerable RunTurnMessageStreamingAtOriginAsync return Stream.RunTurnStreamingCoreAsync(userInput, origin, cancellationToken); } - /// - /// Ends the running turn where the caller cut the reply off. - /// - public bool Interrupt( - string utteranceUntilInterrupt, - TimeSpan durationUntilInterrupt, - bool cutsRunningTurn = true) + /// + public Task StartTurnAsync( + ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken = default) + { + return Stream.StartTurnAsync(userInput, origin, cancellationToken); + } + + /// + public bool Cut(int turnIndex, TurnCut cut) + { + return Cuts.Cut(turnIndex, cut); + } + + /// + public bool Recut(int turnIndex, TurnCut cut) { - return Interruptions.Interrupt(utteranceUntilInterrupt, durationUntilInterrupt, cutsRunningTurn); + return Cuts.Recut(turnIndex, cut); } /// Closes the conversation, and writes the last event of its chain. @@ -394,8 +395,8 @@ public bool EndConversation(ConversationEndReason reason) } /// - /// Disposes this conversation's background sessions and shell executors. Idempotent: a session already - /// disposed, or one that never had either, disposes nothing. + /// Refuses every later turn, waits for a running one to end, and disposes this conversation's background sessions + /// and shell executors. Idempotent: a session already disposed, or one that never had either, disposes nothing. /// public ValueTask DisposeAsync() { @@ -411,10 +412,10 @@ public Task FlushTranscriptAsync() } /// - /// Reads the state this conversation would resume from, as it stands right now. The provider state is - /// read off the live bag with no turn lock around it, so a host serializing mid-turn gets the - /// bag as it stands at that instant, not a turn-boundary snapshot. A graph row that reuses - /// its session instead attaches that session's last turn-end serialization. + /// Reads the state this conversation would resume from, as it stands right now. A host serializing mid-turn + /// gets the provider state off the live bag at that instant, not a turn-boundary snapshot, read under the turn + /// lock so it never falls inside a catch-up's swap. A graph row that reuses its session instead attaches that + /// session's last turn-end serialization. /// internal ConversationSessionState Snapshot() { diff --git a/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs b/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs index 67b40d61..dc396d89 100644 --- a/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs +++ b/src/AgentCore.Application/Runtime/ConversationSessionFactory.cs @@ -69,7 +69,9 @@ public ConversationSession Create(string? conversationId = null, ConversationSes { string resolvedConversationId = string.IsNullOrWhiteSpace(conversationId) ? Guid.NewGuid().ToString("N") : conversationId; - ConversationWorkspace? workspace = _workspaceRoot is null ? null : ConversationWorkspace.Create(_workspaceRoot, resolvedConversationId); + ConversationWorkspace? workspace = _workspaceRoot is null + ? null + : ConversationWorkspace.Create(_workspaceRoot, resolvedConversationId, _seams.Time, _seams.Logger); ConversationSession session = new( resolvedConversationId, diff --git a/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs b/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs index 37261653..ef737bec 100644 --- a/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs +++ b/src/AgentCore.Application/Runtime/ConversationSessionLifetime.cs @@ -1,5 +1,7 @@ +using AgentCore.Application.Diagnostics; using AgentCore.Application.Runtime.Harness; using AgentCore.Domain.Audit; +using Microsoft.Agents.AI; namespace AgentCore.Application.Runtime { @@ -7,10 +9,12 @@ internal sealed class ConversationSessionLifetime { private readonly ConversationSession _session; - // The background release runs once per conversation: both DisposeAsync and the turn-end path reach - // it, and a second release would re-cancel children that already went away. + private readonly Lock _replacedGate = new(); + private int _backgroundReleased; + private Task _replacedReleases = Task.CompletedTask; + internal ConversationSessionLifetime(ConversationSession session) { ArgumentNullException.ThrowIfNull(session); @@ -28,17 +32,17 @@ internal ConversationSessionLifetime(ConversationSession session) /// internal bool EndConversation(ConversationEndReason reason) { - // The event is written before the flag moves. A value outside the closed set therefore ends - // no conversation and writes nothing. bool wrote = _session.Events.EndConversation(reason, _session.Time.GetUtcNow()); + _session.IsComplete = true; + DeleteWorkspace(); return wrote; } /// - /// Deletes this conversation's workspace, if it has one. Called once the chain has recorded why the - /// conversation ended, from every path that can end it. + /// Deletes this conversation's workspace, if it has one. Called when the conversation ends, and by the + /// session owner when it closes or unloads the session. /// internal void DeleteWorkspace() { @@ -46,15 +50,71 @@ internal void DeleteWorkspace() } /// - /// Disposes this conversation's background sessions and shell executors. Idempotent: a session already - /// disposed, or one that never had either, disposes nothing. + /// Stamps this conversation's workspace with now, if it has one. Called at the start of every turn, and by + /// the held-session idle poll while a running turn or a background child keeps the session alive past its + /// idle timeout, so a boot sweep sharing the workspace root never mistakes an active folder for a dead one. + /// + internal void TouchWorkspace() + { + _session.WorkspaceRoot?.Touch(_session.Time, _session.Logger); + } + + /// + /// Refuses every later turn, waits for a turn still holding the conversation to end, then disposes this + /// conversation's background sessions and shell executors, and waits for the release of every session a + /// catch-up replaced. Idempotent: a session already disposed, or one that never had either, disposes nothing. /// internal async ValueTask DisposeAsync() { + await _session.Cuts.CloseTurnsAsync().ConfigureAwait(false); await ReleaseBackgroundSessionsAsync().ConfigureAwait(false); + await ReplacedReleases().ConfigureAwait(false); await DisposeShellsAsync().ConfigureAwait(false); } + /// + /// Starts the release of a session a catch-up replaced, off the turn path, and keeps it for + /// to wait for. Its outcome is logged, never thrown. + /// + /// The session the catch-up swapped out. Nothing may read it any more. + internal void ReleaseReplaced(AgentSession replaced) + { + if (_session.Compiled.BackgroundProviders.Count == 0) + { + return; + } + + Task release = Task.Run(() => ReleaseReplacedAsync(replaced)); + + lock (_replacedGate) + { + _replacedReleases = Task.WhenAll(_replacedReleases, release); + } + } + + private async Task ReleaseReplacedAsync(AgentSession replaced) + { + try + { + await BackgroundSessionRelease.ReleaseAsync( + _session.Compiled.BackgroundProviders, replaced, _session.ConversationId, _session.Logger, CancellationToken.None).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Nothing awaits this release until the conversation is disposed, so nothing may escape it. + catch (Exception exception) +#pragma warning restore CA1031 + { + SessionOwnerLog.BackgroundReleaseFailed(_session.Logger, _session.ConversationId, exception); + } + } + + private Task ReplacedReleases() + { + lock (_replacedGate) + { + return _replacedReleases; + } + } + /// /// Cancels the background children still running on this conversation's session and releases it, once /// per conversation. Skipped when the conversation never opened a session or the document names no background @@ -82,5 +142,53 @@ internal ValueTask DisposeShellsAsync() { return _session.Shells?.DisposeAsync() ?? ValueTask.CompletedTask; } + + /// + /// Gets whether this conversation's document declares any background: children at all, whether or + /// not one has ever actually run. The held-session idle check reads this to decide whether it must poll + /// for a running child at all, or can keep the single sleep to the idle timeout it always had. + /// + internal bool MayHaveBackgroundChildren => _session.Compiled.BackgroundProviders.Count > 0; + + /// + /// Gets whether a background: child this conversation started is still running. The held-session + /// idle check polls this to keep the session while a child outlives the turn that started it (issue #31). + /// + /// + /// is a snapshot, not something a caller can + /// await, so this only answers "right now": the poller in HeldSession calls it again later. A + /// provider that cannot read its own session state — a state bag shaped for a different provider version, + /// say — throws instead of answering; that is read as "still running" rather than let the idle timer + /// unload a session out from under a child that, for all this can tell, is still there. + /// +#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. + internal bool HasRunningBackgroundChild() + { + if (_session.Compiled.BackgroundProviders.Count == 0 || _session.Ledger.Session() is not { } session) + { + return false; + } + + foreach (BackgroundAgentsProvider provider in _session.Compiled.BackgroundProviders) + { + try + { + if (provider.GetIncompleteTasks(session).Count > 0) + { + return true; + } + } +#pragma warning disable CA1031 // The idle timer thread must never crash on a provider's snapshot fault. + catch (Exception exception) +#pragma warning restore CA1031 + { + SessionOwnerLog.BackgroundChildCheckFailed(_session.Logger, _session.ConversationId, exception); + return true; + } + } + + return false; + } +#pragma warning restore MAAI001 } } diff --git a/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs b/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs index 2765ab0a..9a363330 100644 --- a/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs +++ b/src/AgentCore.Application/Runtime/ConversationSessionStateStore.cs @@ -2,6 +2,7 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Conversation; using AgentCore.Application.Runtime.Harness; +using AgentCore.Application.State; namespace AgentCore.Application.Runtime { @@ -87,31 +88,47 @@ void Dropped(string reason) } /// - /// Reads the state this conversation would resume from, as it stands right now. The provider state is - /// read off the live bag with no turn lock around it, so a host serializing mid-turn gets the - /// bag as it stands at that instant, not a turn-boundary snapshot. A graph row that reuses - /// its session instead attaches that session's last turn-end serialization. + /// Takes the state another session of this conversation stored after this one last ran a turn. The session + /// then holds what a session opened on that state now would: its stage, and its slots and no others. + /// + /// The state store 0 holds, newer than this session's. + internal void CatchUp(ConversationSessionState stored) + { + if (stored.Version == ConversationSessionState.CurrentVersion) + { + _session.State.ClearWrittenSlots(); + _ = ConstStateWriter.Apply(_session.State); + } + + Restore(stored); + } + + /// + /// Reads the state this conversation would resume from, as it stands right now. A host serializing mid-turn + /// gets the provider state off the live bag at that instant, not a turn-boundary snapshot. The read holds the + /// turn lock so that it never falls inside a catch-up, which swaps the session and moves the turn index + /// together. A graph row that reuses its session instead attaches that session's last turn-end serialization. /// internal ConversationSessionState Snapshot() { - lock (_session.InterruptLock) + lock (_session.TurnLock) { if (_session.AgentSession is null && _session.Checkpoint is { } held) { return held; } - } - return new() - { - Stage = _session.State.Stage, - IsComplete = _session.IsComplete, - Slots = _session.State.WrittenSlots(), - NextTurnIndex = _session.State.TurnIndex, - Clarifications = _session.Clarifications.Spent(), - Providers = HarnessSessionState.Capture(_session.AgentSession, _session.Compiled.HarnessStateKeys), - WorkflowState = _session.GraphBlob, - }; + return new() + { + Stage = _session.State.Stage, + IsComplete = _session.IsComplete, + Slots = _session.State.WrittenSlots(), + NextTurnIndex = _session.State.TurnIndex, + Clarifications = _session.Clarifications.Spent(), + Providers = HarnessSessionState.Capture(_session.AgentSession, _session.Compiled.HarnessStateKeys), + WorkflowState = _session.GraphBlob, + }; + } } /// Names the state this conversation resumes from when store 0 holds none of its own. @@ -119,7 +136,7 @@ internal void Resume(ConversationSessionState stored) { ArgumentNullException.ThrowIfNull(stored); - lock (_session.InterruptLock) + lock (_session.TurnLock) { if (_session.AgentSession is not null) { diff --git a/src/AgentCore.Application/Runtime/ConversationStoreReads.cs b/src/AgentCore.Application/Runtime/ConversationStoreReads.cs new file mode 100644 index 00000000..b9c2731b --- /dev/null +++ b/src/AgentCore.Application/Runtime/ConversationStoreReads.cs @@ -0,0 +1,180 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; + +namespace AgentCore.Application.Runtime +{ + /// + /// Reads what store 1 holds beyond a session's words, and counts the session's writes the store did not take, so a + /// catch-up knows whether another session wrote in their place. + /// + internal sealed class ConversationStoreReads : ITranscriptLossCounter + { + private readonly ConversationSession _session; + + private readonly TranscriptLosses _losses = new(); + + internal ConversationStoreReads(ConversationSession session) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + } + + /// + /// Gets whether the store lost a write of this session since it last caught up: it refused a turn, or the write + /// failed. The session's words then no longer match the store, so its next turn reads the store back first. + /// + internal bool Diverged => _losses.Any; + + /// + /// Gets whether the store refused a turn of this session because another session of the conversation saved that + /// turn first, and the session has not caught up since. Such a session is never filed for a later request to + /// resume: the ids already name the session whose turn the store kept. A write that merely failed is not a + /// refusal: nothing else holds its ids, and store 0 outranks a filed session on resume. + /// + internal bool Refused => _losses.Refused; + + /// Records that the store refused a turn this session committed. + internal void MarkRefused() + { + _losses.Lose([], refused: true); + } + + /// + public void Dropped(int turnIndex, IReadOnlyList appended, Exception exception) + { + _losses.Lose(appended, exception is ConversationTurnConflictException); + _session.Events.RaiseDroppedTranscriptWrite(turnIndex, exception); + } + + /// Records that the session took what read, and every loss counted before that read. + internal void CaughtUp(TranscriptCatchUp catchUp) + { + _losses.CaughtUp(catchUp.LossMark); + } + + /// + public void Withdrew(IReadOnlyList messageIds) + { + _losses.Withdrew(messageIds); + } + + /// + public async ValueTask JudgeAsync(AgentSession session, CancellationToken cancellationToken) + { + return Diverged && await ReadAsync(session, cancellationToken).ConfigureAwait(false) is { } read + ? new TranscriptLossVerdict(read.LossMark, read.Overtaken) + : null; + } + + /// + public void Realigned(TranscriptLossVerdict verdict) + { + _losses.Realigned(verdict); + } + + /// + /// Reads store when someone else wrote to the conversation since the session's words were last in line with + /// it, or always once the store lost one of this session's writes. Changes nothing. + /// + /// The session of this conversation. + /// Cancels the reads. + /// What store 1 holds, or when it holds nothing the session lacks, or cannot be read. + internal async ValueTask ReadAsync(AgentSession opened, CancellationToken cancellationToken) + { + TranscriptPosition position = _session.History.Position(opened); + await position.Written.ConfigureAwait(false); + + // Read once the writes above landed or dropped, so every loss among them is counted. + (int mark, IReadOnlyList since) = _losses.Read(); + + try + { + ConversationRecord refreshed = await RecordAsync(cancellationToken).ConfigureAwait(false); + + // A lost turn's words took the same ordinals another session's saved turn did, so the counters can + // match while the words differ. + if (since.Count == 0 && refreshed.NextOrdinal == position.NextOrdinal) + { + return null; + } + + IReadOnlyList rows = await _session.Compiled.ConversationStore.ReadForSessionAsync( + _session.ConversationId, cancellationToken).ConfigureAwait(false); + + return new TranscriptCatchUp(position.Revision, rows, refreshed.NextOrdinal, refreshed.State) + { + LossMark = mark, + Overtaken = since.Count > 0 + && await OvertakenAsync(since, rows, refreshed.NextOrdinal, position.NextOrdinal, cancellationToken).ConfigureAwait(false), + }; + } +#pragma warning disable CA1031 // A store that cannot be read never ends a conversation: the turn runs on the words the session holds. + catch (Exception exception) when (exception is not OperationCanceledException) +#pragma warning restore CA1031 + { + Log.TranscriptResyncFailed(_session.Logger, _session.ConversationId, _session.State.TurnIndex, exception); + _session.Events.RaiseFailedTranscriptResync(_session.State.TurnIndex, exception); + return null; + } + } + + /// Whether another session's writes stand in the store in place of this session's lost ones. + /// The losses since the last catch-up. + /// What store 1 read for the session. + /// The next ordinal by store 0's own counter. + /// The next ordinal as the session counted it. + /// Cancels the reads. + private async ValueTask OvertakenAsync( + IReadOnlyList since, IReadOnlyList rows, int stored, int own, CancellationToken cancellationToken) + { + if (since.Any(static loss => loss.Refused || loss.Overtaken)) + { + return true; + } + + HashSet read = new(rows.Select(static row => row.MessageId), StringComparer.Ordinal); + int unlanded = 0; + HashSet untold = [0]; + + foreach (TranscriptLoss loss in since) + { + if (loss.Appended.Count > 0 && loss.Held.Count == 0) + { + untold.UnionWith([.. untold.Select(rowsUntold => rowsUntold + loss.Appended.Count)]); + } + else if (!await LandedAsync(loss.Held, read, cancellationToken).ConfigureAwait(false)) + { + unlanded += loss.Appended.Count; + } + } + + return !untold.Contains(own - unlanded - stored); + } + + /// Whether the store holds every one of these rows, including a row a newer summary now stands in for. + private async ValueTask LandedAsync(IReadOnlyList held, HashSet read, CancellationToken cancellationToken) + { + foreach (string messageId in held) + { + if (!read.Contains(messageId) + && await _session.Compiled.ConversationStore.OrdinalOfAsync(_session.ConversationId, messageId, cancellationToken).ConfigureAwait(false) is null) + { + return false; + } + } + + return true; + } + + /// Store 0's record of this conversation, which must exist for a session that already opened it. + /// Cancels the read. + private async ValueTask RecordAsync(CancellationToken cancellationToken) + { + return await _session.Compiled.ConversationStore.GetAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Store 0 holds no conversation '{_session.ConversationId}' for its own session to resync against."); + } + } +} diff --git a/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs b/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs index e6e07502..b8294e4e 100644 --- a/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs +++ b/src/AgentCore.Application/Runtime/ConversationTranscriptLedger.cs @@ -1,6 +1,4 @@ using AgentCore.Application.Conversation; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Ports; using AgentCore.Application.Runtime.Harness; using AgentCore.Application.Transcript; using Microsoft.Agents.AI; @@ -16,26 +14,187 @@ internal ConversationTranscriptLedger(ConversationSession session) { ArgumentNullException.ThrowIfNull(session); _session = session; + Reads = new ConversationStoreReads(session); } + /// Gets the reads of what store 1 holds beyond the session's words, and the count of its lost writes. + internal ConversationStoreReads Reads { get; } + /// - /// Opens the session of this conversation, once, and re-syncs it with store 0 before handing it to every - /// later turn. + /// Opens the session for one turn, and reads what store 1 holds beyond its words. Changes no words the + /// session already holds: the turn is not admitted yet, and a refused turn must leave them as they are. /// + /// Cancels the open and the reads. + /// + /// The session, and what brings in once the turn is admitted: + /// for a session opened just now, or one whose words store 1 already matches. + /// + internal async ValueTask<(AgentSession Session, TranscriptCatchUp? CatchUp)> OpenForTurnAsync(CancellationToken cancellationToken) + { + if (Session() is { } opened) + { + return (opened, await Reads.ReadAsync(opened, cancellationToken).ConfigureAwait(false)); + } + + return (await OpenSessionAsync(cancellationToken).ConfigureAwait(false), null); + } + + /// + /// Brings an admitted turn's session in line with what store 1 held. A read made before the turn was admitted + /// is stale once the session's own words moved, say because the turn before committed, so it is made again: no + /// turn can commit now, and the words are never replaced by rows that lack them. + /// + /// The session of this conversation. + /// What read, or . + /// Cancels the reads. + /// Another session's turn ended the conversation. + internal async ValueTask CatchUpAsync(AgentSession opened, TranscriptCatchUp? catchUp, CancellationToken cancellationToken) + { + if (catchUp is not null) + { + await BringInAsync(opened, catchUp, cancellationToken).ConfigureAwait(false); + } + + _session.Runner.RefuseIfComplete(); + } + + private async ValueTask BringInAsync(AgentSession opened, TranscriptCatchUp catchUp, CancellationToken cancellationToken) + { + if (!await TakeStoredAsync(opened, catchUp, StateToTake(catchUp), cancellationToken).ConfigureAwait(false)) + { + if (await Reads.ReadAsync(opened, cancellationToken).ConfigureAwait(false) is not { } again + || !await TakeStoredAsync(opened, again, StateToTake(again), cancellationToken).ConfigureAwait(false)) + { + return; + } + + catchUp = again; + } + + Reads.CaughtUp(catchUp); + } + + /// The state another session stored that this one takes, or when it keeps its own. + private ConversationSessionState? StateToTake(TranscriptCatchUp catchUp) + { + return catchUp.State is { } stored && (catchUp.Overtaken || stored.NextTurnIndex > _session.State.TurnIndex) ? stored : null; + } + + /// + /// Takes the words store 1 holds, with the turn index, the stage, the slots, and the harness providers' state or + /// a graph row's workflow another session stored beside them. + /// + /// The session of this conversation. + /// What store 1 held. + /// The state to take, or when the session keeps its own. + /// Cancels the session's rebuild. + /// when the session's words moved since the read and nothing was taken. + private async ValueTask TakeStoredAsync( + AgentSession opened, TranscriptCatchUp catchUp, ConversationSessionState? stored, CancellationToken cancellationToken) + { + if (stored is { Version: ConversationSessionState.CurrentVersion } current + && _session.SessionCarriesHistory && _session.Compiled.HarnessStateKeys.Count > 0) + { + return await RebuildAsync(opened, catchUp, current, cancellationToken).ConfigureAwait(false); + } + + ConversationSessionState? workflow = _session.ReusesGraphSession && stored is { Version: ConversationSessionState.CurrentVersion } + ? stored + : null; + AgentSession? graph = workflow is null ? null : await GraphSessionFromAsync(workflow, cancellationToken).ConfigureAwait(false); + + if (!_session.History.TryResync(opened, catchUp)) + { + return false; + } + + lock (_session.TurnLock) + { + if (workflow is not null) + { + _session.GraphSession = graph; + _session.GraphBlob = workflow.WorkflowState; + } + + TakeState(catchUp, stored); + } + + return true; + } + + /// + /// Replaces the session with one read from the state another session stored, holding the words store 1 read, + /// and releases the old one. + /// + /// The session of this conversation. + /// What store 1 held. + /// The state store 0 holds. + /// Cancels the read of the new session. + /// when the old session's words moved since the read and it was kept. + private async ValueTask RebuildAsync( + AgentSession opened, TranscriptCatchUp catchUp, ConversationSessionState stored, CancellationToken cancellationToken) + { + if (_session.History.Position(opened).Revision != catchUp.Revision) + { + return false; + } + + AgentSession rebuilt = await SessionFromAsync(stored, cancellationToken).ConfigureAwait(false); + + lock (_session.TurnLock) + { + if (_session.History.Position(opened).Revision != catchUp.Revision) + { + return false; + } + + _ = _session.History.BeginConversation( + rebuilt, + _session.ConversationId, + catchUp.Rows, + Reads, + new TranscriptMarks(catchUp.NextOrdinal, stored.NextTurnIndex)); + + _session.AgentSession = rebuilt; + TakeState(catchUp, stored); + } + + _session.Lifetime.ReleaseReplaced(opened); + + return true; + } + + /// + /// Moves the turn index on past the turns store 1 holds, and takes the stage and slots another session stored. + /// The caller holds , and swaps the session or the workflow in the same + /// section: a snapshot must never pair one session's providers with another's turn index. + /// + /// What store 1 held. + /// The state to take, or when the session keeps its own. + private void TakeState(TranscriptCatchUp catchUp, ConversationSessionState? stored) + { + if (stored is not null) + { + _session.States.CatchUp(stored); + } + + _session.State.TurnIndex = Math.Max(_session.State.TurnIndex, catchUp.NextTurnIndex); + } + + /// Opens the session of this conversation, once, and hands the same session to every later caller. /// Cancels the open. /// The session. internal async ValueTask OpenSessionAsync(CancellationToken cancellationToken) { if (Session() is { } opened) { - await ResyncTranscriptAsync(opened, cancellationToken).ConfigureAwait(false); return opened; } ConversationRecord record = await _session.Compiled.ConversationStore.CreateAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false); ConversationSessionState? checkpoint; - lock (_session.InterruptLock) + lock (_session.TurnLock) { checkpoint = _session.Checkpoint; } @@ -45,10 +204,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel AgentSession session; if (_session.SessionCarriesHistory) { - session = stored is { Version: ConversationSessionState.CurrentVersion, Providers.Count: > 0 } - ? await _session.Compiled.TurnAgent.DeserializeSessionAsync( - HarnessSessionState.Wrap(stored.Providers), cancellationToken: cancellationToken).ConfigureAwait(false) - : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + session = await SessionFromAsync(stored, cancellationToken).ConfigureAwait(false); } else { @@ -56,9 +212,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel if (_session.ReusesGraphSession) { - _session.GraphSession = stored is { Version: ConversationSessionState.CurrentVersion, WorkflowState: { } blob } - ? await _session.Compiled.TurnAgent.DeserializeSessionAsync(blob, cancellationToken: cancellationToken).ConfigureAwait(false) - : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + _session.GraphSession = await GraphSessionFromAsync(stored, cancellationToken).ConfigureAwait(false); } } @@ -66,7 +220,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel ? [] : await _session.Compiled.ConversationStore.ReadForSessionAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false); - lock (_session.InterruptLock) + lock (_session.TurnLock) { if (_session.AgentSession is null) { @@ -76,7 +230,7 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel session, _session.ConversationId, spoken, - _session.Events.RaiseDroppedTranscriptWrite, + Reads, new TranscriptMarks(record.NextOrdinal, stored?.NextTurnIndex ?? 0)); if (stored is { } s) @@ -89,88 +243,21 @@ internal async ValueTask OpenSessionAsync(CancellationToken cancel } } - /// - /// Brings the session's words back in line with store 1 before a turn after the first, when and - /// only when someone else wrote to the conversation in between. - /// - /// The session of this conversation. - /// Cancels the reads. - private async ValueTask ResyncTranscriptAsync(AgentSession opened, CancellationToken cancellationToken) - { - await _session.History.DrainAsync(opened).ConfigureAwait(false); - - try - { - ConversationRecord refreshed = await RecordAsync("resync against", cancellationToken).ConfigureAwait(false); - - if (refreshed.NextOrdinal == _session.History.NextOrdinal(opened)) - { - return; - } - - IReadOnlyList rows = await _session.Compiled.ConversationStore.ReadForSessionAsync( - _session.ConversationId, cancellationToken).ConfigureAwait(false); - - _session.History.Resync(opened, rows, refreshed.NextOrdinal); - } -#pragma warning disable CA1031 // A store that cannot be read never ends a conversation: the turn runs on the words the session holds. - catch (Exception exception) when (exception is not OperationCanceledException) -#pragma warning restore CA1031 - { - Log.TranscriptResyncFailed(_session.Logger, _session.ConversationId, _session.State.TurnIndex, exception); - _session.Events.RaiseFailedTranscriptResync(_session.State.TurnIndex, exception); - } - } - - /// - /// Cuts an edit that reaches under the summary: the parent is a row the summary stands in for, - /// or the edit takes the whole conversation. The caller asks - /// first and comes here when it says no. - /// - /// The session of this conversation. - /// The message the edit hangs off, or for the whole conversation. - /// Cancels the cut. - /// The turns the cut withdrew, or when nothing went. - internal async ValueTask CutUnderSummaryAsync( - AgentSession opened, string? parentMessageId, CancellationToken cancellationToken) + /// The session a conversation that carries its history runs on: one read from the stored state, or a fresh one. + private async ValueTask SessionFromAsync(ConversationSessionState? stored, CancellationToken cancellationToken) { - IConversationStore store = _session.Compiled.ConversationStore; - - await _session.History.DrainAsync(opened).ConfigureAwait(false); - - int from; - if (parentMessageId is null) - { - from = 0; - } - else if (await store.OrdinalOfAsync(_session.ConversationId, parentMessageId, cancellationToken).ConfigureAwait(false) is { } parent) - { - from = parent + 1; - } - else - { - return null; - } - - ConversationCut cut = await store.TruncateAsync(_session.ConversationId, from, cancellationToken).ConfigureAwait(false); - Log.ConversationTruncated(_session.Logger, _session.ConversationId, from, _session.State.TurnIndex); - - ConversationRecord refreshed = await RecordAsync("edit", cancellationToken).ConfigureAwait(false); - IReadOnlyList rows = await store.ReadForSessionAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false); - - _session.History.Resync(opened, rows, refreshed.NextOrdinal); - - return cut.Turns; + return stored is { Version: ConversationSessionState.CurrentVersion, Providers.Count: > 0 } + ? await _session.Compiled.TurnAgent.DeserializeSessionAsync( + HarnessSessionState.Wrap(stored.Providers), cancellationToken: cancellationToken).ConfigureAwait(false) + : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } - /// Store 0's record of this conversation, which must exist for a session that already opened it. - /// What the session is about to do with it, for the error. - /// Cancels the read. - private async ValueTask RecordAsync(string purpose, CancellationToken cancellationToken) + /// The workflow session a graph row that reuses one resumes from: the stored one, or a fresh one. + private async ValueTask GraphSessionFromAsync(ConversationSessionState? stored, CancellationToken cancellationToken) { - return await _session.Compiled.ConversationStore.GetAsync(_session.ConversationId, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException( - $"Store 0 holds no conversation '{_session.ConversationId}' for its own session to {purpose}."); + return stored is { Version: ConversationSessionState.CurrentVersion, WorkflowState: { } blob } + ? await _session.Compiled.TurnAgent.DeserializeSessionAsync(blob, cancellationToken: cancellationToken).ConfigureAwait(false) + : await _session.Compiled.TurnAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } /// @@ -179,14 +266,14 @@ private async ValueTask RecordAsync(string purpose, Cancella /// The session. internal AgentSession? Session() { - lock (_session.InterruptLock) + lock (_session.TurnLock) { return _session.AgentSession; } } /// - /// Waits for every store 1 write this conversation has queued. + /// Waits for every store write this conversation has queued. /// /// A task that completes when the words of the conversation are durable. internal async Task FlushTranscriptAsync() diff --git a/src/AgentCore.Application/Runtime/ConversationTurn.cs b/src/AgentCore.Application/Runtime/ConversationTurn.cs index d56a694f..5f73cd3e 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurn.cs +++ b/src/AgentCore.Application/Runtime/ConversationTurn.cs @@ -13,7 +13,6 @@ namespace AgentCore.Application.Runtime internal sealed record ConversationTurn( AIAgent Agent, AgentSession Session, - List Request, ChatMessage Spoken, string? Reminder, string StageBefore, @@ -24,5 +23,5 @@ internal sealed record ConversationTurn( TurnResults Results, TurnFiles Files, KnowledgeScope? Knowledge, - string? MessageId); + ConversationTurnOrigin? Origin); } diff --git a/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs b/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs new file mode 100644 index 00000000..7b0b6f9e --- /dev/null +++ b/src/AgentCore.Application/Runtime/ConversationTurnAgent.cs @@ -0,0 +1,244 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Conversation; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime +{ + /// + /// The outermost turn layer, and the one trigger of a turn's durable write. On entry it runs the turn's edit; + /// when the run ends it seals the turn once: the completer decides it, the provider commits it in one append, + /// and the ids of that append ride the turn's last update. + /// + /// The layered agent of one entry or one stage. + /// Store 1, the only door to the conversation's words. + internal sealed class ConversationTurnAgent(AIAgent inner, AgentCoreChatHistoryProvider history) : DelegatingAIAgent(inner) + { + /// The cut of a run the host cancelled or the reader abandoned: it keeps everything yielded (W05, W06). + private static readonly TurnCut Stopped = new(ShownText: null, Played: null); + + private readonly AgentCoreChatHistoryProvider _history = history ?? throw new ArgumentNullException(nameof(history)); + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + if (Filed(options) is null) + { + return await base.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + } + + List updates = []; + await foreach (AgentResponseUpdate update in RunCoreStreamingAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + if (!update.Contents.OfType().Any()) + { + updates.Add(update); + } + } + + return updates.ToAgentResponse(); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Filed(options) is not { } turn) + { + await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + + yield break; + } + + IEnumerable request = await EnterAsync(turn, messages, cancellationToken).ConfigureAwait(false); + + List seen = []; + Exception? fault = null; + bool hostCancelled = false; + Seal seal = default; + IAsyncEnumerator stream = base + .RunCoreStreamingAsync(request, session, options, cancellationToken) + .GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + try + { + if (!await stream.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + } + catch (OperationCanceledException) when (turn.CutSlot.IsCut) + { + break; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + hostCancelled = true; + break; + } +#pragma warning disable CA1031 // Section 8.7, row six: a fault above every fallback still ends in a sealed turn. + catch (Exception exception) when (exception is not OperationCanceledException) +#pragma warning restore CA1031 + { + fault = exception; + break; + } + + seen.Add(stream.Current); + yield return stream.Current; + } + + seal = await SealAsync(turn, seen, fault, hostCancelled ? Stopped : null).ConfigureAwait(false); + } + finally + { + await stream.DisposeAsync().ConfigureAwait(false); + + // A reader that stops reading disposes this stream mid-run, and the turn keeps what it was + // yielded (design row W06). A turn already sealed seals nothing here. + _ = await SealAsync(turn, seen, fault, Stopped).ConfigureAwait(false); + } + + // The host asked to stop: the turn is committed, and the host still gets its cancellation. + if (hostCancelled) + { + throw new OperationCanceledException(cancellationToken); + } + + if (seal.Refused) + { + throw new ConversationTurnConflictException( + $"Another request saved turn {turn.Invocation.TurnIndex} of conversation '{turn.Invocation.ConversationId}' " + + "first, so the words of this one were not kept."); + } + + if (seal.Ids is { } committed) + { + yield return new AgentResponseUpdate(ChatRole.Assistant, [new TurnCommittedContent(committed.UserMessageId, committed.ReplyMessageId)]); + } + } + + private static FiledTurn? Filed(AgentRunOptions? options) + { + return TurnInvocation.From(options) is + { + Nested: false, + HistorySession: { } historySession, + User: { } user, + Completer: { } completer, + CutSlot: { } slot, + } invocation + ? new FiledTurn(invocation, historySession, user, completer, slot) + : null; + } + + /// Runs the turn's edit, and builds the request the run gets. + private async ValueTask> EnterAsync( + FiledTurn turn, IEnumerable messages, CancellationToken cancellationToken) + { + if (turn.Invocation.Origin is { NamesParent: true } origin) + { + WithdrawnTurns? withdrawn = _history.CanTruncateFrom(turn.HistorySession, origin.ParentMessageId) + ? await _history.TruncateFromAsync(turn.HistorySession, origin.ParentMessageId, cancellationToken).ConfigureAwait(false) + : await _history.CutUnderSummaryAsync(turn.HistorySession, origin.ParentMessageId, cancellationToken).ConfigureAwait(false); + + if (withdrawn is { } taken) + { + turn.Completer.Superseded(taken); + } + } + + return turn.Invocation.RendersHistory + && TurnMessages.GraphHistory(_history.Read(turn.HistorySession)) is { } rendered + ? [rendered, .. messages] + : messages; + } + + /// Seals the turn: decide it, write it once, and raise its events. + /// The turn being sealed. + /// Every update the run yielded. + /// The fault the run threw, or . + /// The cut to seal with when no cut reached the slot, or for none. + /// + /// The ids the commit wrote, which are when the turn was already sealed or the session + /// holds no conversation, and whether the store refused them. + /// + private async ValueTask SealAsync( + FiledTurn turn, List seen, Exception? fault, TurnCut? stopped) + { + if (!turn.CutSlot.TrySeal(out TurnCut? cut)) + { + return default; + } + + cut ??= stopped; + + TurnCommit sealing = new(turn.User) + { + Seen = seen.ToAgentResponse(), + Disposition = ConversationTurnStream.ReadDisposition(seen), + Cut = cut, + CallerFacing = !turn.Invocation.CarriesHistory, + UserMessageId = turn.Invocation.Origin?.MessageId, + }; + + TurnCommit commit = await turn.Completer.CompleteAsync(sealing, fault).ConfigureAwait(false); + + TurnWrite? written; + lock (turn.CutSlot.Gate) + { + written = _history.CommitTurn(turn.HistorySession, commit); + } + + // Another session of the conversation may have saved this turn first. The store's answer decides + // whether the turn is published at all, so it is awaited here, outside the lock. A cut that arrives + // meanwhile waits as a late cut, exactly as one between the seal and the commit does. + if (written is not null && await turn.Completer.RefusedAsync(written.Refused).ConfigureAwait(false)) + { + return new Seal(null, Refused: true); + } + + // A session that holds no conversation writes no rows, so the words are read off what the rows would be. + (string UserMessageId, string? ReplyMessageId)? ids = written?.Ids; + + string spoken = written?.Spoken ?? TurnWords.Spoken(TurnWords.Compose(commit, [])); + + lock (turn.CutSlot.Gate) + { + TurnCut? late = turn.CutSlot.Commit(out TurnCut? recut); + turn.Completer.Committed(ids, spoken, late, recut); + } + + await turn.Completer.FinishAsync().ConfigureAwait(false); + + return new Seal(ids, Refused: false); + } + + /// What sealing a turn wrote, and whether the store refused it. + private readonly record struct Seal((string UserMessageId, string? ReplyMessageId)? Ids, bool Refused); + + private sealed record FiledTurn( + TurnInvocation Invocation, + AgentSession HistorySession, + ChatMessage User, + ITurnCompleter Completer, + TurnCutSlot CutSlot); + } +} diff --git a/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs b/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs index f9e03a88..392d7db1 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs +++ b/src/AgentCore.Application/Runtime/ConversationTurnCommit.cs @@ -1,14 +1,19 @@ +using AgentCore.Application.Diagnostics; using AgentCore.Domain; using AgentCore.Domain.Audit; namespace AgentCore.Application.Runtime { /// - /// The publish half of a turn: appends the transcript, writes the turn's events, and tears the - /// conversation down when it is over. decides the turn; this commits it. + /// The publish half of a turn: writes the turn's events once its words are committed, and tears the + /// conversation down when it is over. decides the turn; this publishes it. /// internal sealed class ConversationTurnCommit { + /// The failure a refused turn's span ends with. + internal const string RefusedReason = + "the store refused the turn's words: another session of the conversation saved the same turn first."; + private readonly ConversationSession _session; internal ConversationTurnCommit(ConversationSession session) @@ -17,50 +22,76 @@ internal ConversationTurnCommit(ConversationSession session) _session = session; } - internal TurnResult Publish( - ConversationTurn turn, - ReplyOutcome outcome, - TranscriptEntry addition, - TurnResult result, - ConversationInterruptionTracker.Interruption? interruption) + /// + /// Waits for the store's answer to a turn's append, however long it takes, and marks the session refused when + /// the store refused it. + /// + /// Completes with when the store refused the append. It never faults. + /// when the store refused the turn. + internal async ValueTask RefusedAsync(Task refused) { - lock (_session.InterruptLock) + if (!await refused.ConfigureAwait(false)) { - _session.LastReplyMessageId = _session.SessionCarriesHistory - ? _session.History.AppendTurn( - turn.Session, [turn.Spoken, .. addition.Written], _session.Snapshot(), turn.MessageId) - : _session.History.AppendCallerFacingTurn( - turn.Session, turn.Spoken, addition.Heard, _session.Snapshot(), turn.MessageId); + return false; + } - Guid completedEventId = _session.Events.WriteTurnEvents(result, outcome.SpokenReply, outcome.ToolFault); + _session.Ledger.Reads.MarkRefused(); + return true; + } - _session.AmendableEventId = outcome.InterruptedAfter is null ? completedEventId : null; + /// Publishes one committed turn. Runs under the turn lock, right after the commit. + /// What the commit wrote the user's message and the last message under, or . + /// The words the turn's rows say. An uncut turn's turn.completed and a cut turn's + /// reply.interrupted hash them, so the verify query rebuilds exactly what the chain proves. + /// What the turn said and what the caller heard. + /// The finished turn. + /// The cut the turn was sealed with, or . + /// + /// A barge-in that reached the turn after its seal, to amend the reply just written, or . + /// When is set, it is a recut that replaces that cut. + /// + /// The turn as published, amended when landed. + internal TurnResult Publish( + (string UserMessageId, string? ReplyMessageId)? ids, + string spoken, + ReplyOutcome outcome, + TurnResult result, + TurnCut? cut, + TurnCut? late) + { + _session.LastReplyMessageId = ids is { } written ? written.ReplyMessageId ?? written.UserMessageId : null; - _session.LastTurn = result; + if (outcome.Fault is { } fault && outcome.IsToolFault) + { + Log.ToolBudgetSpent(_session.Logger, result.ConversationId, result.TurnIndex, fault); + } - if (interruption is null - && _session.Interruption is { } late - && _session.Interruptions.AmendLastTurn(late) - && _session.LastTurn is { } amended) - { - result = amended; - } + // A cut turn's rows hold what was shown, so its turn.completed proves what the model produced instead. + Guid completedEventId = _session.Events.WriteTurnEvents( + result, cut is null ? spoken : outcome.GeneratedText, spoken, outcome.ToolFault, cut?.Played); - _session.Interruption = null; + _session.AmendableEventId = completedEventId; + _session.AmendableText = spoken; - _session.RunCancellation = null; + _session.LastTurn = result; - _session.Interruptions.RunIsAudible = false; + if (late is not null + && _session.Cuts.AmendTurn(result.TurnIndex, late, recut: cut is not null) + && _session.LastTurn is { } amended) + { + result = amended; } return result; } - internal async Task EndConversationIfOverAsync(DateTimeOffset endedAt, string stageAfter) + /// Closes the chain of a conversation the turn left in a terminal stage, and releases what it held. + /// The stage the turn left the conversation in. + internal async Task EndConversationIfOverAsync(string stageAfter) { if (_session.IsComplete) { - _ = _session.Events.EndConversation(ConversationEndReason.AgentCompleted, endedAt, stageAfter); + _ = _session.Events.EndConversation(ConversationEndReason.AgentCompleted, _session.Time.GetUtcNow(), stageAfter); } if (!_session.IsComplete && !_session.Events.HasEnded) diff --git a/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs b/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs index 62ce8a8b..30964b09 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs +++ b/src/AgentCore.Application/Runtime/ConversationTurnCompletion.cs @@ -1,6 +1,7 @@ -using System.Text.Json; +using System.Diagnostics; +using System.Globalization; using AgentCore.Application.Diagnostics; -using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; using AgentCore.Domain; using AgentCore.Domain.Audit; using Microsoft.Agents.AI; @@ -8,91 +9,111 @@ namespace AgentCore.Application.Runtime { - internal sealed class ConversationTurnCompletion + /// + /// The conversation's side of one turn's seal: it decides the turn after its run, and publishes it once + /// committed the words. One instance serves one turn. + /// + internal sealed class ConversationTurnCompletion : ITurnCompleter { private readonly ConversationSession _session; + private readonly ConversationTurn _turn; + private readonly ConversationTurnCommit _commit; - internal ConversationTurnCompletion(ConversationSession session) + private ReplyOutcome? _outcome; + + private TurnResult? _decided; + + private TurnCut? _cut; + + /// Creates the completion of one turn. + /// The conversation. + /// The turn it completes. + internal ConversationTurnCompletion(ConversationSession session, ConversationTurn turn) { ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(turn); _session = session; + _turn = turn; _commit = new ConversationTurnCommit(session); } + /// Gets the finished turn, or until the turn is published. + internal TurnResult? Result { get; private set; } + + /// + public void Superseded(WithdrawnTurns withdrawn) + { + _session.Clarifications.Withdraw(); + + _ = _session.Events.Raise( + ConversationEventKind.TurnSuperseded, + _session.Time.GetUtcNow(), + _session.State.TurnIndex, + payload: new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.WithdrewFromTurnIndex] = + withdrawn.First.ToString(CultureInfo.InvariantCulture), + [AuditPayloadKeys.WithdrewThroughTurnIndex] = + withdrawn.Last.ToString(CultureInfo.InvariantCulture), + }); + } + /// Runs every writer, then lets the machine pick the stage of the next turn. - /// The turn that just spoke. - /// What the agent answered. - /// - /// The message of the fault that threw out of the run, or when nothing - /// threw. Section 8.7, row six. - /// - /// Cancels the extractor call. - /// The finished turn. - /// - /// What and reported about - /// this turn, or when neither layer marked it. A flagged turn skips the + /// + /// The turn as the layer saw it end. Its disposition is what and + /// reported. A flagged turn skips the /// extractor: the words moderation flagged are its only input, and a slot filled from them would /// carry the flagged content into the state document and into every later prompt. Everything else /// about a refused turn is ordinary, so the refusal enters the transcript, the stage machine /// advances, and turn.completed proves the refusal under /// . /// - internal async Task CompleteTurnAsync( - ConversationTurn turn, - AgentResponse response, - string? toolFault, - TurnDisposition? disposition, - CancellationToken cancellationToken) + /// + /// The fault that threw out of the run, or when nothing threw. Section 8.7, + /// row six. + /// + /// The commit, with the state after the stage advance and the line the record holds. + public async ValueTask CompleteAsync(TurnCommit sealing, Exception? fault) { - // Sweep drain state for tool calls whose round never reached the client's drain: the - // round that ends a turn attaches nothing, so turn-scoped records would otherwise - // leave entries sitting on the shared clients. + ArgumentNullException.ThrowIfNull(sealing); + + Activity.Current = _turn.Activity; + + ConversationTurn turn = _turn; + + AgentResponse response = sealing.Seen ?? new AgentResponse(); + + TurnDisposition? disposition = sealing.Disposition; + + _cut = sealing.Cut; + turn.Results.Sweep(); - // Calls the model named that no tool answered never reach the invoking client, so no - // per-call state names them. The turn's own record does: every id the model gave minus - // every id a tool ran under. Reported here, at the same turn end that sweeps the drain. ReportUndeclaredToolCalls(turn, response); - ConversationInterruptionTracker.Interruption? interruption = _session.Interruptions.CurrentInterruption(); - - // The moderation facts of this turn, raised before the turn's own events so the flag still - // takes the lower ordinal. The verdict is known before the model runs, which is the one rule - // that separates prompt.flagged from reply.interrupted: it amends nothing. bool refused = disposition?.Moderation is ModerationOutcome.Flagged; - _session.Events.RaiseModeration(turn.Index, disposition); - ReplyOutcome outcome = ResolveReply(turn, response, interruption, disposition, toolFault); - TranscriptEntry addition = TranscriptAddition(response, outcome); + _session.Events.RaiseModeration(turn.Index, disposition); - // The per-turn writers run from here, in the order the constructor documents: tool results - // first. + ReplyOutcome outcome = TurnReplyResolution.Resolve(_session, turn, response, _cut, disposition, fault, sealing.CallerFacing); _session.Writers.ApplyToolResults(response.Messages); - // A refused turn runs no extractor. The words moderation flagged are the extractor's only - // input, so a slot filled from them would carry the flagged content into the state document - // and into every later prompt. The refusal itself says nothing worth extracting either, and - // the extractor costs a model call, so this also spends nothing on a turn nobody answered. - // A pending turn runs none either: the run suspended instead of answering, so there is - // nothing decided to read. string? extractionFailure = refused || outcome.Approvals.Count > 0 ? null - : await ExtractAsync(turn, response, cancellationToken).ConfigureAwait(false); + : await ExtractAsync(turn, response).ConfigureAwait(false); - // The clock fields write next. The clock comes from the injected provider, so a test owns - // it. The same read stamps every audit event of this turn. DateTimeOffset endedAt = _session.Time.GetUtcNow(); + _session.State.TurnIndex++; _session.State.ConversationDurationSeconds = (endedAt - _session.StartedAt).TotalSeconds; - // The counters write last. _ = _session.Counters.Apply(_session.State); string stageAfter = AdvanceStage(turn); - TurnResult result = new( + _decided = new( _session.ConversationId, turn.Index, turn.StageBefore, @@ -106,146 +127,109 @@ internal async Task CompleteTurnAsync( { Approvals = outcome.Approvals, }; + _outcome = outcome; - // A graph row that reuses its session persists it here, before the commit below reads - // it into store 0: the next turn resumes from these checkpoints, and a resumed conversation - // rebuilds them. Rows 1 and 2 need nothing — their session outlives the conversation. if (_session.ReusesGraphSession && _session.GraphSession is { } shared) { - _session.GraphBlob = await turn.Agent.SerializeSessionAsync(shared, cancellationToken: cancellationToken).ConfigureAwait(false); + _session.GraphBlob = await turn.Agent.SerializeSessionAsync(shared).ConfigureAwait(false); } - result = _commit.Publish(turn, outcome, addition, result, interruption); - - AgentCoreTelemetry.EndTurn( - turn.Activity, - _session.Time.GetElapsedTime(turn.StartedAt), - Outcome(result.Failure, result.InterruptedAfter), - stageAfter, - result.Failure); - - await _commit.EndConversationIfOverAsync(endedAt, stageAfter).ConfigureAwait(false); - - return result; + return sealing with + { + Completed = outcome.InterruptedAfter is null && outcome.Failure is null, + Reply = outcome.Reply, + State = _session.Snapshot(), + }; } - private ReplyOutcome ResolveReply( - ConversationTurn turn, - AgentResponse response, - ConversationInterruptionTracker.Interruption? interruption, - TurnDisposition? disposition, - string? toolFault) + /// + public async ValueTask RefusedAsync(Task refused) { - // R1 reaches the turn through the fallback layer, and a fault above every chat client — a - // graph that matched no edge — still reaches the catch in the run methods. Both are the - // same row-six fact. - toolFault ??= disposition is { Fallback: FallbackCause.Faulted, FallbackReason: { } caught } - ? caught - : null; - - string reply = SpokenReply.From(response.Messages); - string spokenReply = reply; - TimeSpan? interruptedAfter = null; - string? failure = toolFault is null ? null : ConversationSession.ToolFailureReason + " " + toolFault; - - // The tool calls still waiting on a human answer. A pending turn is not a failure: the - // run suspended instead of answering, so the empty-reply substitution below is skipped, - // the extractor has nothing decided to read, and the requests ride TurnResult to the host. - List approvals = ApprovalRequestsOf(response); - - if (interruption is { } cut) + if (!await _commit.RefusedAsync(refused).ConfigureAwait(false)) { - // Item 6a. The record holds the text the caller heard, not the text the model produced. - // A trailing space is not speech, so it is trimmed: pipecat pins the same rule in its - // aggregator test, where the model sent "Hello " and the record holds "Hello". - reply = cut.HeardText.Trim(); - interruptedAfter = cut.PlayedDuration; - } - else if (approvals.Count == 0 && (failure is not null - || string.IsNullOrWhiteSpace(reply) - || disposition?.Fallback is FallbackCause.EmptyReply)) - { - // Section 8.7, last row. A quiet run is silence on a voice conversation, so an empty reply is a - // failure even though nothing threw. - failure ??= ConversationSession.EmptyReplyReason; - reply = _session.Compiled.FallbackReply; - spokenReply = reply; + return false; } - // Section 8.7, last row, raised once for the turn. It is diagnostic only, so it takes no - // ordinal and no row records it; the turn.completed event of this same turn carries the - // fallback the caller actually heard. The tool fault of row six is raised in - // WriteTurnEvents instead, because the chain stores that one and its row is stamped with - // the same endedAt every other event of the turn carries. - if (toolFault is null && failure is not null) - { - _session.Events.RaiseDiagnostic(ConversationEventKind.EmptyReply, _session.Time.GetUtcNow(), turn.Index); - } + TurnRefusals.Raise(_session, _turn.Index, TurnRefusals.Conflict); - return new ReplyOutcome(reply, spokenReply, failure, toolFault, interruptedAfter, approvals); + AgentCoreTelemetry.EndTurn( + _turn.Activity, + _session.Time.GetElapsedTime(_turn.StartedAt), + AgentCoreTelemetry.OutcomeFailed, + _turn.StageBefore, + ConversationTurnCommit.RefusedReason); + + return true; } - // Built before the writers run and committed at the end of the turn, in one lock with the rest - // of what a late barge-in may still amend: the transcript span, the ordinal the amendment must - // reference, and LastTurn itself. Nothing in between reads the transcript — the extractor reads - // the finished turn, and the writers read the state document — so building it early and - // committing it once costs nothing. - private TranscriptEntry TranscriptAddition(AgentResponse response, ReplyOutcome outcome) + /// + public void Committed((string UserMessageId, string? ReplyMessageId)? ids, string spoken, TurnCut? late, TurnCut? recut) { - // A barge-in inside the first 100 ms leaves no heard text, and an empty assistant message - // teaches the model nothing. pipecat and livekit both guard this. Both row shapes read this - // one value, so what counts as words the caller heard is decided once. - ChatMessage? heard = outcome.Reply.Length > 0 ? new ChatMessage(ChatRole.Assistant, outcome.Reply) : null; - - // Rows 3 and 4 record the caller-facing turn alone, so `written` stays empty for them: a - // node's tool pair and a node's line to the next node are neither said nor heard, and the - // commit takes the heard reply straight. - if (!_session.SessionCarriesHistory) + if (_outcome is not { } outcome || _decided is not { } decided) { - return new TranscriptEntry(heard, []); + throw new InvalidOperationException("The turn was committed before it was completed."); } - if (outcome.InterruptedAfter is null && outcome.Failure is null) - { - return new TranscriptEntry(heard, [.. response.Messages]); - } + // A barge-in after the seal amends the reply just written, unless a cut already shaped it; a recut + // amends the reply that cut shaped. + Result = _commit.Publish(ids, spoken, outcome, decided, _cut, _cut is null ? late : recut); + } - // The transcript holds what the caller heard. A run that stopped mid-round would - // otherwise leave a tool call with no result behind, and the next turn would send - // it. So the pairs that finished are kept and only an unpaired call is dropped: a - // side effect that ran must stay visible to the next turn. livekit/agents fixed the - // same defect in issue 3702. - List written = [.. TurnMessages.FinishedToolMessages(response.Messages)]; - if (heard is not null) + /// + public async ValueTask FinishAsync() + { + if (Result is not { } result) { - written.Add(heard); + throw new InvalidOperationException("The turn was finished before it was committed."); } - return new TranscriptEntry(heard, written); + AgentCoreTelemetry.EndTurn( + _turn.Activity, + _session.Time.GetElapsedTime(_turn.StartedAt), + Outcome(result.Failure, result.Cut), + result.StageAfter, + result.Failure, + ErrorType(result.Failure)); + + await _commit.EndConversationIfOverAsync(result.StageAfter).ConfigureAwait(false); + } + + /// Reads the error.type value a failed turn's span carries. The metric point keeps its outcome alone. + /// The reason the record itself holds, or when the turn answered. + /// + /// The exception's own type when caught one, the closed + /// empty-reply token when it did not, or when the turn did not fail. Never + /// the exception's message: that can carry caller or model text, and + /// already holds it for whoever reads itself. + /// + private string? ErrorType(string? failure) + { + return (failure, _outcome?.Fault) switch + { + (null, _) => null, + (_, { } fault) => fault.GetType().FullName, + _ => AgentCoreTelemetry.FailureEmptyReply, + }; } // The extractor's deadline is here and not on the whole turn, because the raise of the turn's // events and the stage advance must always run. - private async Task ExtractAsync(ConversationTurn turn, AgentResponse response, CancellationToken cancellationToken) + private async Task ExtractAsync(ConversationTurn turn, AgentResponse response) { string? extractionFailure; - using CancellationTokenSource deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - deadline.CancelAfter(ConversationSession.TurnCompletionTimeout); + using CancellationTokenSource deadline = new(ConversationSession.TurnCompletionTimeout, _session.Time); try { extractionFailure = await _session.Writers.ExtractAsync(turn, response, deadline.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (deadline.IsCancellationRequested) { extractionFailure = ConversationSession.ExtractionTimedOutReason; } if (extractionFailure is not null) { - // Section 8.7, row two: leave the slots unchanged, report once for the turn, and continue - // the conversation. State extraction must never drop a conversation. The reason rides on the event - // because the line an operator reads is the same one the turn loop used to write itself. _session.Events.RaiseDiagnostic( ConversationEventKind.ExtractionFailed, _session.Time.GetUtcNow(), @@ -286,31 +270,6 @@ private static string Outcome(string? failure, TimeSpan? interruptedAfter) }; } - /// Reads the approval requests one finished turn still waits on. - /// What the agent answered. - /// One entry per request content, oldest first; empty when the turn asked nothing. - private static List ApprovalRequestsOf(AgentResponse response) - { - List approvals = []; - foreach (ToolApprovalRequestContent request in response.Messages - .SelectMany(message => message.Contents) - .OfType()) - { - if (request.ToolCall is not FunctionCallContent call) - { - continue; - } - - approvals.Add(new PendingApproval( - request.RequestId, - call.Name, - JsonSerializer.SerializeToElement( - call.Arguments ?? new Dictionary(StringComparer.Ordinal)))); - } - - return approvals; - } - /// Reports calls the model named that no tool answered. /// The turn that just ran. /// What the agent answered. diff --git a/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs b/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs index 92ed6453..177f5825 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs +++ b/src/AgentCore.Application/Runtime/ConversationTurnRunner.cs @@ -1,12 +1,8 @@ using System.Diagnostics; -using System.Globalization; using AgentCore.Application.Diagnostics; using AgentCore.Application.Knowledge; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.State; -using AgentCore.Application.Transcript; -using AgentCore.Domain; -using AgentCore.Domain.Audit; using AgentCore.Domain.Knowledge; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -49,95 +45,55 @@ internal void SetTimeZone(TimeZoneInfo zone) _timeZone = zone; } - /// Runs one turn of the conversation against the session the conversation holds. - /// What the caller said or answered. - /// Where the turn hangs, or null for a caller that does not say. - /// Cancels the turn. - /// What the turn did. - internal async Task RunTurnCoreAsync( - ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken) + /// + /// Admits one turn: refuses a terminal or disposed conversation, and waits for a running turn of this + /// conversation to end, up to counted from . + /// follows. The turn's edit runs later, in . + /// + /// The at which the turn began to wait for the conversation. + /// Cancels the wait. + /// The conversation was disposed. + /// The conversation is terminal. + /// Another turn still ran past the limit. + internal async Task AdmitTurnAsync(long started, CancellationToken cancellationToken) { - AgentSession session = await _session.Ledger.OpenSessionAsync(cancellationToken).ConfigureAwait(false); - - await AdmitTurnAsync(session, origin, cancellationToken).ConfigureAwait(false); - ConversationTurn turn = BeginTurn(userInput, session, origin); - - CancellationTokenSource cancellation = _session.Interruptions.StartRun(cancellationToken); + RefuseIfTerminal(); + await _session.Cuts.EnterTurnAsync(started, cancellationToken).ConfigureAwait(false); try { - TurnInvocation invocation = TurnInvocationOf(turn); - - AgentSession runSession = await OpenRunAsync(turn, cancellation.Token).ConfigureAwait(false); - - TurnRegistry.Set(runSession, invocation); - - AgentResponse response; - string? toolFault = null; - - try - { - response = await turn.Agent - .RunAsync( - turn.Request, - runSession, - invocation.RunOptions(), - cancellationToken: cancellation.Token) - .ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - toolFault = exception.Message; - response = new AgentResponse(); - } - - return await _session.Completion.CompleteTurnAsync(turn, response, toolFault, ConversationTurnStream.ReadDisposition(response), cancellationToken) - .ConfigureAwait(false); + RefuseIfTerminal(); } - finally + catch { - _session.Interruptions.EndRun(cancellation); - turn.Activity?.Dispose(); + _session.Cuts.ReleaseTurn(); + throw; } + + _session.Clarifications.BeginTurn(); } - /// - /// Admits one turn: refuses a terminal or running conversation, then withdraws whatever the turn - /// replaces. follows, from the same frame. - /// - /// The session of this conversation. - /// Where the turn hangs, or null for a caller that does not say. - /// Cancels the withdrawal. - internal async ValueTask AdmitTurnAsync(AgentSession session, ConversationTurnOrigin? origin, CancellationToken cancellationToken) + private void RefuseIfTerminal() { - if (_session.IsComplete) + if (!_session.Ledger.Reads.Diverged || _session.Events.HasEnded) { - throw new InvalidOperationException( - $"The conversation '{_session.ConversationId}' reached the terminal stage '{_session.Stage}', so it runs no further turn."); + RefuseIfComplete(); } + } - if (!_session.Interruptions.TryEnterTurn()) + /// Refuses a turn on a conversation that reached a terminal stage. + /// The conversation reached a terminal stage. + internal void RefuseIfComplete() + { + if (_session.IsComplete) { throw new InvalidOperationException( - $"A turn of the conversation '{_session.ConversationId}' is still running. One conversation runs one turn at a time."); - } - - try - { - _session.Clarifications.BeginTurn(); - - await WithdrawSupersededAsync(session, origin, cancellationToken).ConfigureAwait(false); - } - catch - { - _session.Interruptions.ReleaseTurn(); - - throw; + $"The conversation '{_session.ConversationId}' reached the terminal stage '{_session.Stage}', so it runs no further turn."); } } /// - /// Picks the agent, builds the model input, and takes the turn admitted. + /// Picks the agent and takes the turn admitted. /// Synchronous on purpose: the turn span it opens must be the ambient activity of the caller's /// frame, and an async method hands no back. /// @@ -150,6 +106,8 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, Activity? activity = null; try { + _session.Lifetime.TouchWorkspace(); + AIAgent agent = ResolveAgent(); string? reminder = _session.Policy is null ? null : UnfilledSlotReminder.Build(_session.State, _session.Policy.CurrentStage); @@ -158,14 +116,6 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, _session.History.BeginTurn(session, _session.State.TurnIndex); - List request = [spoken]; - if (!_session.SessionCarriesHistory - && !_session.ReusesGraphSession - && TurnMessages.GraphHistory(_session.History.Read(session)) is { } rendered) - { - request = [rendered, spoken]; - } - activity = AgentCoreTelemetry.StartTurn(_session.ConversationId, _session.State.TurnIndex, _session.State.Stage); KnowledgeScope? knowledge = StateKnowledgeScope.Compose( @@ -179,7 +129,6 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, return new ConversationTurn( agent, session, - request, spoken, reminder, _session.State.Stage, @@ -190,50 +139,18 @@ internal ConversationTurn BeginTurn(ChatMessage userInput, AgentSession session, new TurnResults(), new TurnFiles(), knowledge, - origin?.MessageId); + origin); } catch { activity?.Dispose(); - _session.Interruptions.ReleaseTurn(); + _session.Cuts.ReleaseTurn(); throw; } } - /// Takes back everything the conversation said after the message this turn hangs off. - internal async ValueTask WithdrawSupersededAsync(AgentSession session, ConversationTurnOrigin? origin, CancellationToken cancellationToken) - { - if (origin is not { NamesParent: true }) - { - return; - } - - WithdrawnTurns? cut = _session.History.CanTruncateFrom(session, origin.ParentMessageId) - ? _session.History.TruncateFrom(session, origin.ParentMessageId) - : await _session.Ledger.CutUnderSummaryAsync(session, origin.ParentMessageId, cancellationToken).ConfigureAwait(false); - - if (cut is not { } withdrawn) - { - return; - } - - _session.Clarifications.Withdraw(); - - _ = _session.Events.Raise( - ConversationEventKind.TurnSuperseded, - _session.Time.GetUtcNow(), - _session.State.TurnIndex, - payload: new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.WithdrewFromTurnIndex] = - withdrawn.First.ToString(CultureInfo.InvariantCulture), - [AuditPayloadKeys.WithdrewThroughTurnIndex] = - withdrawn.Last.ToString(CultureInfo.InvariantCulture), - }); - } - /// Picks the agent that speaks this turn. /// The agent. internal AIAgent ResolveAgent() @@ -274,10 +191,12 @@ internal async ValueTask OpenRunAsync(ConversationTurn turn, Cance return await turn.Agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } - /// Builds the invocation one turn hands its tools. + /// Builds the invocation one turn hands its layers and its tools. /// The turn about to run. - /// Everything a tool of this turn may need, as one explicit value. - internal TurnInvocation TurnInvocationOf(ConversationTurn turn) + /// The conversation's side of the turn's seal. + /// Where a cut of the turn waits for the seal. + /// Everything the seal and a tool of this turn may need, as one explicit value. + internal TurnInvocation TurnInvocationOf(ConversationTurn turn, ITurnCompleter completer, TurnCutSlot slot) { return new() { @@ -299,6 +218,13 @@ internal TurnInvocation TurnInvocationOf(ConversationTurn turn) Tools = _delegatedTools?.Tools, ToolsFor = _delegatedTools?.ToolId, State = _session.State, + HistorySession = turn.Session, + User = turn.Spoken, + Origin = turn.Origin, + RendersHistory = !_session.SessionCarriesHistory && !_session.ReusesGraphSession, + Completer = completer, + CutSlot = slot, + Notices = new TurnNotices(), }; } } diff --git a/src/AgentCore.Application/Runtime/ConversationTurnStream.cs b/src/AgentCore.Application/Runtime/ConversationTurnStream.cs index f00624a1..52a09ae7 100644 --- a/src/AgentCore.Application/Runtime/ConversationTurnStream.cs +++ b/src/AgentCore.Application/Runtime/ConversationTurnStream.cs @@ -1,5 +1,10 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Ports; using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Transcript; +using AgentCore.Domain; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -27,43 +32,139 @@ internal IAsyncEnumerable RunTurnStreamingCoreAsync( { ArgumentNullException.ThrowIfNull(userInput); - return RunTurnStreamingIteratorAsync(userInput, origin, cancellationToken); + return RunTurnStreamingIteratorAsync(userInput, origin, result: null, cancellationToken); + } + + /// Runs one turn to its end over the streaming core, and returns what it did. + /// What the caller said or answered. + /// Where the turn hangs, or null for a caller that does not say. + /// Cancels the turn. + /// What the turn did. + internal async Task RunTurnCoreAsync( + ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userInput); + + StrongBox result = new(); + + await foreach (ChatResponseUpdate _ in RunTurnStreamingIteratorAsync(userInput, origin, result, cancellationToken) + .ConfigureAwait(false)) + { + } + + return result.Value!; + } + + /// Starts one turn: the turn is admitted and filed, and reaches it. + /// What the caller said or answered. + /// Where the turn hangs, or null for a caller that does not say. + /// Cancels the turn, from the start through its last update. + /// The turn's index and its reply. The reply runs only as it is enumerated. + internal async Task StartTurnAsync( + ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userInput); + + (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, cancellationToken).ConfigureAwait(false); + + return new TurnRun( + turn.Index, + RunAsync(turn, slot, result: null, new StrongBox()), + abandon: () => AbandonRun(turn, slot)); } /// Streams one turn. The caller checks the arguments. + /// What the caller said or answered. + /// Where the turn hangs, or null for a caller that does not say. + /// Receives what the turn did, for a caller that collects the turn, or . + /// Cancels the model calls. private async IAsyncEnumerable RunTurnStreamingIteratorAsync( ChatMessage userInput, ConversationTurnOrigin? origin, + StrongBox? result, [EnumeratorCancellation] CancellationToken cancellationToken) { - AgentSession session = await _session.Ledger.OpenSessionAsync(cancellationToken).ConfigureAwait(false); + (ConversationTurn turn, TurnCutSlot slot) = await StartAsync(userInput, origin, cancellationToken).ConfigureAwait(false); + + await foreach (ChatResponseUpdate update in RunAsync(turn, slot, result, new StrongBox()).ConfigureAwait(false)) + { + yield return update; + } + } - await _session.Runner.AdmitTurnAsync(session, origin, cancellationToken).ConfigureAwait(false); - ConversationTurn turn = _session.Runner.BeginTurn(userInput, session, origin); + private async Task<(ConversationTurn Turn, TurnCutSlot Slot)> StartAsync( + ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken) + { + long started = _session.Time.GetTimestamp(); - // A streaming turn becomes audible only once it hands the host its first piece of content. - // Until then nothing of it has reached the caller, and a barge-in belongs elsewhere. - CancellationTokenSource cancellation = _session.Interruptions.StartRun(cancellationToken); + // The mark comes before every read: a turn of another session still filing its words holds it, and the + // reads below must see those words and the turn index after them. + await _session.Busy.EnterAsync(cancellationToken).ConfigureAwait(false); try { - List updates = []; - string? toolFault = null; + (AgentSession session, TranscriptCatchUp? catchUp) = await _session.Ledger.OpenForTurnAsync(cancellationToken).ConfigureAwait(false); - TurnInvocation invocation = _session.Runner.TurnInvocationOf(turn) with { Notices = new TurnNotices() }; - AgentSession runSession = await _session.Runner.OpenRunAsync(turn, cancellation.Token).ConfigureAwait(false); + // A turn another door started on this session waits here; the catch-up below reads what it committed. + await _session.Runner.AdmitTurnAsync(started, cancellationToken).ConfigureAwait(false); + try + { + await _session.Ledger.CatchUpAsync(session, catchUp, cancellationToken).ConfigureAwait(false); + } + catch + { + _session.Cuts.ReleaseTurn(); + throw; + } + + ConversationTurn turn = _session.Runner.BeginTurn(userInput, _session.Ledger.Session() ?? session, origin); + + return (turn, _session.Cuts.StartRun(turn.Index, cancellationToken)); + } + catch + { + _ = _session.Busy.Exit(); + throw; + } + } + + /// Runs one started turn to its seal, and frees the conversation for the next. + /// The turn began. + /// Where a cut of the turn waits for the seal. + /// Receives what the turn did, for a caller that collects the turn, or . + /// Set once the reply is enumerated. A second enumeration would end the run twice. + private async IAsyncEnumerable RunAsync( + ConversationTurn turn, TurnCutSlot slot, StrongBox? result, StrongBox enumerated) + { + if (Interlocked.Exchange(ref enumerated.Value, 1) != 0) + { + throw new InvalidOperationException( + $"The reply of turn {turn.Index} of the conversation '{_session.ConversationId}' is already being read."); + } + + // The span opened in StartAsync's frame; an async method hands no Activity.Current back. + if (turn.Activity is { } span) + { + Activity.Current = span; + } + + try + { + ConversationTurnCompletion completion = new(_session, turn); + TurnInvocation invocation = _session.Runner.TurnInvocationOf(turn, completion, slot); + AgentSession runSession = await _session.Runner.OpenRunAsync(turn, slot.Token).ConfigureAwait(false); TurnRegistry.Set(runSession, invocation); IAsyncEnumerable runStream = turn.Agent .RunStreamingAsync( - turn.Request, + [turn.Spoken], runSession, invocation.RunOptions(), - cancellationToken: cancellation.Token); + cancellationToken: slot.Token); IAsyncEnumerator stream = TurnUpdateMerge - .RunAsync(runStream, invocation.Notices, cancellation.Token) - .GetAsyncEnumerator(cancellation.Token); + .RunAsync(runStream, invocation.Notices!, slot.Token) + .GetAsyncEnumerator(slot.Token); try { @@ -80,32 +181,25 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync update = stream.Current; } - catch (OperationCanceledException) when (_session.Interruptions.CurrentInterruption() is not null) + catch (OperationCanceledException) when (slot.IsCut) { break; } - catch (Exception exception) when (exception is not OperationCanceledException) - { - toolFault = exception.Message; - - break; - } - if (update.Contents.OfType().Any()) + // Rides through like a notice: it carries no text of its own, so a caller + // that reads only IsOutput-gated content never sees it unless it asks. + if (update.Contents.OfType().Any() + || update.Contents.OfType().Any()) { yield return update.AsChatResponseUpdate(); continue; } - updates.Add(update); - ChatResponseUpdate content = update.AsChatResponseUpdate(); - - if (TurnMessages.CarriesContent(content) && Speaks(update)) - { - _session.Interruptions.RunIsAudible = true; + if (TurnMessages.CarriesContent(content) && IsOutput(update)) + { yield return content; } } @@ -115,23 +209,53 @@ private async IAsyncEnumerable RunTurnStreamingIteratorAsync await stream.DisposeAsync().ConfigureAwait(false); } - _ = await _session.Completion.CompleteTurnAsync( - turn, updates.ToAgentResponse(), toolFault, ReadDisposition(updates), cancellationToken) - .ConfigureAwait(false); + result?.Value = completion.Result + ?? throw new InvalidOperationException( + $"The turn {turn.Index} of the conversation '{_session.ConversationId}' ended without a seal."); } finally { - _session.Interruptions.EndRun(cancellation); - turn.Activity?.Dispose(); + EndRun(turn, slot); } } + /// Closes the turn's cut window and span, and frees the conversation for the next turn, here and in the store. + private void EndRun(ConversationTurn turn, TurnCutSlot slot) + { + _session.Cuts.EndRun(slot); + turn.Activity?.Dispose(); + _ = _session.Busy.Exit(); + } + + /// + /// Ends a turn whose was disposed before its reply was ever read. The turn's + /// iterator never started, so nothing in ran: no model call, no seal, and + /// without this, no outcome tag and no turn.duration point either, though the span + /// opened is still live and must still end. + /// It is given the outcome and duration a stopped turn gets, because that is exactly what it is: + /// a turn cut before it produced anything, with nothing played. It keeps none of its words, so it is refused + /// as dropped, and the refusal leaves its log line and its audit row. + /// + private void AbandonRun(ConversationTurn turn, TurnCutSlot slot) + { + TurnRefusals.Raise(_session, turn.Index, TurnRefusals.Dropped); + + AgentCoreTelemetry.EndTurn( + turn.Activity, + _session.Time.GetElapsedTime(turn.StartedAt), + AgentCoreTelemetry.OutcomeInterrupted, + turn.StageBefore, + failure: null); + + EndRun(turn, slot); + } + /// Reads whether the caller hears the node that produced one update. /// One update of the run. /// when the host should speak it. - internal bool Speaks(AgentResponseUpdate update) + internal bool IsOutput(AgentResponseUpdate update) { - return _session.Compiled.SpokenBy is not { } spoken + return _session.Compiled.OutputAgents is not { } spoken || (update.AuthorName is { } author && spoken.Contains(author)) || update.AdditionalProperties?.Contains() is true; } diff --git a/src/AgentCore.Application/Runtime/ConversationWorkspace.cs b/src/AgentCore.Application/Runtime/ConversationWorkspace.cs index 84b642d9..868c934c 100644 --- a/src/AgentCore.Application/Runtime/ConversationWorkspace.cs +++ b/src/AgentCore.Application/Runtime/ConversationWorkspace.cs @@ -4,10 +4,22 @@ namespace AgentCore.Application.Runtime { /// - /// The folder one conversation owns on disk: created with the conversation, and deleted when it ends. + /// The folder one conversation owns on disk: created with the session, and deleted when the session closes, unloads, or ends. /// internal sealed class ConversationWorkspace { + /// + /// The suffix appends to a workspace's path to name its marker file, a sibling of + /// the folder rather than a file inside it: root/conversationId.agentcore-workspace. The boot + /// sweep () deletes a + /// folder only when its marker is present, so a workspace root a host points at a folder used for + /// anything else — the current directory, a home directory, a shared volume — never loses a folder + /// AgentCore did not make. Kept outside the folder because a model can read, list, and write inside a + /// workspace: a marker sitting in there would show up in a file listing or a memory index, and would + /// make the folder non-empty for a tool that expects one, such as a bare git clone. + /// + internal const string MarkerFileName = ".agentcore-workspace"; + private ConversationWorkspace(string path) { Path = path; @@ -16,8 +28,19 @@ private ConversationWorkspace(string path) /// Gets the folder's path. public string Path { get; } + /// Gets the path of this workspace's marker file: plus . + internal string MarkerPath => MarkerPathFor(Path); + + /// Gets the marker path a folder at would have. + internal static string MarkerPathFor(string workspacePath) + { + return workspacePath + MarkerFileName; + } + /// - /// Creates (or, for a resumed conversation, reuses) the folder root/conversationId. + /// Creates (or, for a resumed conversation, reuses) the folder root/conversationId: writes its + /// sibling marker the boot sweep looks for, and stamps the folder with now, so a reused folder is never + /// mistaken for one a crashed process left behind before its first turn even runs. /// /// The root a host bound. /// @@ -25,11 +48,14 @@ private ConversationWorkspace(string path) /// checked, not the text of the id, so this also refuses a conversationId of ".", which /// would otherwise resolve straight back to the root. /// + /// The clock to stamp with. Defaults to the system clock. + /// Where a failed marker write or stamp is logged, at Warning. May be . /// /// is empty or white space, or resolves to a path that is not a strict /// child of . /// - public static ConversationWorkspace Create(string root, string conversationId) + public static ConversationWorkspace Create( + string root, string conversationId, TimeProvider? time = null, ILogger? logger = null) { ArgumentException.ThrowIfNullOrEmpty(root); ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); @@ -60,12 +86,66 @@ public static ConversationWorkspace Create(string root, string conversationId) } _ = Directory.CreateDirectory(path); - return new ConversationWorkspace(path); + + ConversationWorkspace workspace = new(path); + + // A marker that cannot be written — its path collides with something else, say — must not stop the + // conversation from getting a working folder. It is simply never a candidate the boot sweep considers. + string marker = workspace.MarkerPath; + try + { + if (!File.Exists(marker)) + { + File.WriteAllText(marker, string.Empty); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (logger is not null) + { + SessionOwnerLog.WorkspaceMarkerWriteFailed(logger, marker, exception); + } + } + + // A reused folder's mtime can be well older than the idle timeout: CreateDirectory above did not + // touch it, since the folder already existed. Stamping here means "held" implies "stamped within + // the idle timeout" from the very moment a caller has the workspace, not only from its first turn. + workspace.Touch(time ?? TimeProvider.System, logger); + return workspace; + } + + /// + /// Stamps the folder's last-write time with 's now. Called at the start of every + /// turn, so the boot sweep on another server sharing the workspace root can tell this folder is still in + /// active use from one a crashed process left behind (see ). + /// Never throws: a failed stamp must not fail the turn it belongs to. + /// + /// The clock to stamp with. A test passes a fake clock so the stamp is deterministic. + /// Where a failed stamp is logged, at Warning. May be . + public void Touch(TimeProvider time, ILogger? logger) + { + ArgumentNullException.ThrowIfNull(time); + + try + { + Directory.SetLastWriteTimeUtc(Path, time.GetUtcNow().UtcDateTime); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A close racing this stamp, or an idle poll racing another touch, can delete the folder in the + // window between the failing call and this check: that is the ordinary outcome of the race, not + // a fault worth logging. The runtime throws FileNotFoundException here, not + // DirectoryNotFoundException, so this checks what is actually on disk rather than one exception type. + if (logger is not null && Directory.Exists(Path)) + { + SessionOwnerLog.WorkspaceStampFailed(logger, Path, exception); + } + } } /// - /// Deletes the folder recursively. Safe to call twice: the second call finds nothing and does - /// not log. Never throws out of a caller ending a conversation. + /// Deletes the folder recursively, and its sibling marker. Safe to call twice: the second call finds + /// nothing and does not log. Never throws out of a caller ending a conversation. /// /// Where a failed delete is logged, at Warning. May be . public void Delete(ILogger? logger) @@ -84,7 +164,20 @@ public void Delete(ILogger? logger) // between the failing operation and this check; that is not a failure worth logging. if (logger is not null && Directory.Exists(Path)) { - Log.WorkspaceDeleteFailed(logger, Path, exception); + SessionOwnerLog.WorkspaceDeleteFailed(logger, Path, exception); + } + } + + string marker = MarkerPath; + try + { + File.Delete(marker); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (logger is not null && File.Exists(marker)) + { + SessionOwnerLog.WorkspaceMarkerDeleteFailed(logger, marker, exception); } } } diff --git a/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs b/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs index ac8d4b52..a8637e8e 100644 --- a/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs +++ b/src/AgentCore.Application/Runtime/DelegatedAgentRun.cs @@ -1,15 +1,14 @@ -using Microsoft.Agents.AI; using System.Text.Json; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime.Harness; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace AgentCore.Application.Runtime { - // Runs one delegated agent directly — the same RunAsync every other run goes through — - // instead of the framework's agent-as-function machinery, which builds the nested run's - // options where no caller can file the turn. The bridge files the nested turn and the - // tools this delegation is offered on the nested run's own options, exactly like the - // loop does for the outer run. One nested run, one fresh session: concurrent delegations - // never share one. +#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. internal static class DelegatedAgentRun { /// Runs one inner agent for one delegating tool call. @@ -17,6 +16,15 @@ internal static class DelegatedAgentRun /// What the outer agent asked, in words. /// The parent turn, stripped per K42 — or null outside a turn. /// The tools this delegation is offered, or null for none. + /// + /// Every background provider the document compiled. The delegation's session is fresh and this + /// call is its only owner, so its background children start releasing off this call's own path + /// when it returns — on fault or cancel too, the same duty a conversation's own end discharges for + /// its session. Awaiting the release inline here, under , would + /// hold the outer turn (and a barge-in on it) for up to + /// 's timeout whenever this delegation started a child that + /// ignores its cancel (R4-5). + /// /// Cancels the run. /// What the inner agent answered, as the outer agent reads it. internal static async ValueTask RunAsync( @@ -24,39 +32,80 @@ internal static class DelegatedAgentRun string query, TurnInvocation? nested, IReadOnlyList? tools, + IReadOnlyList backgroundProviders, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(inner); ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(backgroundProviders); AgentSession session = await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - ChatOptions chat = new() + try { - AdditionalProperties = [], - }; + ChatOptions chat = new() + { + AdditionalProperties = [], + }; + + if (nested is not null) + { + chat.AdditionalProperties[TurnInvocation.ArgumentsKey] = nested; + } + + if (tools is { Count: > 0 }) + { + chat.Tools = [.. tools]; + } - if (nested is not null) + AgentResponse response = await inner.RunAsync( + [new ChatMessage(ChatRole.User, query)], + session, + new ChatClientAgentRunOptions(chat), + cancellationToken).ConfigureAwait(false); + + return response.Text is { } text + ? JsonSerializer.SerializeToElement(text) + : null; + } + finally { - chat.AdditionalProperties[TurnInvocation.ArgumentsKey] = nested; + if (backgroundProviders.Count > 0) + { + ReleaseInBackground( + backgroundProviders, + session, + nested?.ConversationId ?? "(delegation outside a turn)", + nested?.Logger ?? NullLogger.Instance); + } } + } - if (tools is { Count: > 0 }) + /// + /// Starts releasing the delegation's session off the caller's path, and logs the outcome. Nothing + /// here awaits the release, so it can never stall the outer turn or a barge-in on it. + /// + private static void ReleaseInBackground( + IReadOnlyList backgroundProviders, AgentSession session, string conversationId, ILogger logger) + { + _ = Task.Run(() => ReleaseLoggingFailureAsync(backgroundProviders, session, conversationId, logger)); + } + + private static async Task ReleaseLoggingFailureAsync( + IReadOnlyList backgroundProviders, AgentSession session, string conversationId, ILogger logger) + { + try { - chat.Tools = [.. tools]; + await BackgroundSessionRelease.ReleaseAsync( + backgroundProviders, session, conversationId, logger, CancellationToken.None).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Nothing awaits this release once the delegation returns, so nothing may escape it. + catch (Exception exception) +#pragma warning restore CA1031 + { + SessionOwnerLog.BackgroundReleaseFailed(logger, conversationId, exception); } - - AgentResponse response = await inner.RunAsync( - [new ChatMessage(ChatRole.User, query)], - session, - new ChatClientAgentRunOptions(chat), - cancellationToken).ConfigureAwait(false); - - // Marshaled like the framework's own agent-as-function result: the outer loop reads - // words either way, but callers pin the JsonElement shape. - return response.Text is { } text - ? JsonSerializer.SerializeToElement(text) - : null; } } +#pragma warning restore MAAI001 } diff --git a/src/AgentCore.Application/Runtime/FallbackAgent.cs b/src/AgentCore.Application/Runtime/FallbackAgent.cs index 76ad94d7..6973e970 100644 --- a/src/AgentCore.Application/Runtime/FallbackAgent.cs +++ b/src/AgentCore.Application/Runtime/FallbackAgent.cs @@ -7,59 +7,26 @@ namespace AgentCore.Application.Runtime /// /// Answers a run that threw, or one that spoke no words at all, with the spoken fallback. /// - /// - /// - /// R1: a tool that fails four times in a row throws out of the run, and section 8.7 says that must - /// never kill the conversation. R2: at 40 tool rounds the framework sends request 41 with no tools and - /// returns quietly, with no exception — on a voice conversation that silence is the failure, so an empty - /// reply is read rather than trusting the absence of an error. Both end here, and both leave the - /// turn an ordinary successful run. - /// - /// - /// A cancel is not a fault. is never swallowed: a - /// barge-in ends the turn on the caller's terms and the turn loop records what was heard. - /// - /// - /// It wraps an AGENT and not a chat client. One turn is one run of one agent on every row of - /// the compile table. A chat-pipeline layer would answer each graph node separately, and a node that - /// went quiet would then speak the fallback INTO the graph — which is exactly the silent graph - /// failure section 8.2 refuses to ship, hidden rather than raised. - /// - /// - /// A run that threw carries no messages out, because the fault ended it. An empty run keeps the - /// messages it did produce, so a tool pair that finished stays visible to the next turn. - /// - /// - /// On a graph row the emptiness that matters is the OUTPUT node's, not the run's. Measured on - /// Microsoft.Agents.AI 1.17.0: a sequential graph whose last node answers with whitespace returns one - /// message, and it is the node BEFORE it. Reading the run as "it produced text" would put the graph's - /// own deliberation in the caller's ear. The constructor's spokenBy set names the agents whose - /// reply the caller hears, so a quiet output node reads as the silence it is. - /// - /// - /// It holds no per-call state: one instance is compiled once and shared by every conversation under T44 and - /// R7. - /// - /// internal sealed class FallbackAgent : DelegatingAIAgent { private readonly string _fallbackReply; - private readonly IReadOnlySet? _spokenBy; + + private readonly IReadOnlySet? _outputAgents; /// Puts the fallback in front of one turn agent. /// The agent a turn runs. /// What a failed turn speaks. - /// + /// /// The agents.items ids whose reply the caller hears, or when the /// last thing the run produced is that reply. It is null for rows 1 and 2, which run one agent, /// and for the graph patterns where any participant may answer last. /// - public FallbackAgent(AIAgent inner, string fallbackReply, IReadOnlySet? spokenBy = null) + public FallbackAgent(AIAgent inner, string fallbackReply, IReadOnlySet? outputAgents = null) : base(inner) { ArgumentNullException.ThrowIfNull(fallbackReply); _fallbackReply = fallbackReply; - _spokenBy = spokenBy; + _outputAgents = outputAgents; } /// @@ -76,12 +43,12 @@ protected override async Task RunCoreAsync( .ConfigureAwait(false); } #pragma warning disable CA1031 // Section 8.7, row six: the run throws, the turn ends, and the conversation lives. - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (IsFault(exception, cancellationToken)) #pragma warning restore CA1031 { AgentResponse spoken = new(new ChatMessage(ChatRole.Assistant, _fallbackReply)); spoken.AdditionalProperties ??= []; - spoken.AdditionalProperties.Add(Disposition(FallbackCause.Faulted, exception.Message)); + spoken.AdditionalProperties.Add(Disposition(FallbackCause.Faulted, exception)); return spoken; } @@ -112,7 +79,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA bool spokeText = false; FallbackCause cause = FallbackCause.None; - string? reason = null; + Exception? reason = null; try { @@ -127,11 +94,11 @@ protected override async IAsyncEnumerable RunCoreStreamingA } } #pragma warning disable CA1031 // Section 8.7, row six. See the buffered path above. - catch (Exception exception) when (exception is not OperationCanceledException) + catch (Exception exception) when (IsFault(exception, cancellationToken)) #pragma warning restore CA1031 { cause = FallbackCause.Faulted; - reason = exception.Message; + reason = exception; } if (cause is not FallbackCause.None || current is null) @@ -139,7 +106,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA break; } - spokeText = spokeText || HasApprovalRequest(current) || (Speaks(current.AuthorName) && !string.IsNullOrWhiteSpace(current.Text)); + spokeText = spokeText || HasApprovalRequest(current) || (IsOutput(current.AuthorName) && !string.IsNullOrWhiteSpace(current.Text)); yield return current; } } @@ -162,14 +129,21 @@ protected override async IAsyncEnumerable RunCoreStreamingA } } + /// + /// Distinguishes a caller cancel from every other : only the + /// former is not a fault. + /// + /// What the run or the stream threw. + /// The token this run was cancelled by, if the caller cancelled it. + /// when the turn should take the fallback rather than propagate. + private static bool IsFault(Exception exception, CancellationToken cancellationToken) + { + return exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested; + } + /// Reads whether the run asks the caller to approve a tool call. /// What the inner agent answered. /// Whether any message carries an approval request. - /// - /// A request-only reply carries no text, so without this the run reads as silence and takes - /// the fallback. An approval the caller never hears is a tool that never runs, so a request - /// counts as spoken whoever authored it. - /// private static bool HasApprovalRequest(IEnumerable messages) { return messages.Any(message => HasApprovalRequest(message.Contents)); @@ -193,21 +167,21 @@ private static bool HasApprovalRequest(IEnumerable contents) /// The spoken text, or an empty string when nothing the caller hears was produced. private string SpokenText(AgentResponse response) { - return SpokenReply.From(response.Messages, _spokenBy); + return ReplyText.From(response.Messages, _outputAgents); } /// Reads whether the caller hears what this author said. /// The node that produced the message or the update, if any. /// when the text counts as the reply. - private bool Speaks(string? authorName) + private bool IsOutput(string? authorName) { - return _spokenBy is null || (authorName is not null && _spokenBy.Contains(authorName)); + return _outputAgents is null || (authorName is not null && _outputAgents.Contains(authorName)); } /// /// What this layer alone knows. folds its own verdict in above. /// - private static TurnDisposition Disposition(FallbackCause cause, string? reason) + private static TurnDisposition Disposition(FallbackCause cause, Exception? reason) { return new(Moderation: null, FlaggedCategories: null, cause, reason, ModerationReason: null); } diff --git a/src/AgentCore.Application/Runtime/GraphFaultAgent.cs b/src/AgentCore.Application/Runtime/GraphFaultAgent.cs new file mode 100644 index 00000000..9ba97fc5 --- /dev/null +++ b/src/AgentCore.Application/Runtime/GraphFaultAgent.cs @@ -0,0 +1,88 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime +{ + /// + /// Rethrows the fault a graph node raised, and the caller's cancel. AsAIAgent() reports a fault as an + /// update whose is the + /// or , and never throws; without this the + /// turn reads an empty reply. + /// + internal sealed class GraphFaultAgent(AIAgent inner, bool drain) : DelegatingAIAgent(inner) + { + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + // The merged response drops each update's RawRepresentation, so the non-streaming path reads the stream. + return RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Exception? fault = null; + + await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + if (fault is not null) + { + continue; + } + + if (FaultOf(update) is { } thisFault) + { + fault = thisFault; + + if (!drain) + { + break; + } + + continue; + } + + yield return update; + } + + if (fault is not null) + { + ExceptionDispatchInfo.Capture(fault).Throw(); + } + + cancellationToken.ThrowIfCancellationRequested(); + } + + private static Exception? FaultOf(AgentResponseUpdate update) + { + Exception? fault = update.RawRepresentation switch + { + ExecutorFailedEvent failed => failed.Data + ?? new InvalidOperationException($"The graph node '{failed.ExecutorId}' failed, and MAF reported no exception."), + WorkflowErrorEvent error => error.Exception + ?? new InvalidOperationException("The graph reported a workflow error with no exception."), + _ => null, + }; + + while (fault is TargetInvocationException { InnerException: { } inner }) + { + fault = inner; + } + + return fault; + } + } +} diff --git a/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs b/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs index 38ce5802..76e48fed 100644 --- a/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs +++ b/src/AgentCore.Application/Runtime/Harness/BackgroundSessionRelease.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using AgentCore.Application.Diagnostics; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; @@ -6,7 +7,9 @@ namespace AgentCore.Application.Runtime.Harness { /// /// Releases the harness background providers' sessions when a conversation ends, so children still - /// running cannot outlive it. A task that spans turns is untouched until the end. + /// running cannot outlive it. A task that spans turns is untouched until the end, unless another session's + /// turn makes this one rebuild its session from the store: the old session is released then, without the turn + /// waiting for it. /// internal static class BackgroundSessionRelease { @@ -17,12 +20,18 @@ internal static class BackgroundSessionRelease /// private static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(5); + /// + /// How early MAF's timeout can end by 's clock: timers run on a coarse tick of a few + /// milliseconds. + /// + private static readonly TimeSpan TimerSlack = TimeSpan.FromMilliseconds(100); + /// /// Cancels each provider's running tasks on and releases the /// session. Best effort: one provider's fault is logged and the rest still release. /// /// Every background provider the document compiled. - /// The session of the conversation that ended. + /// The session of the conversation that ended, or the one a catch-up replaced. /// The id of the conversation, for the log. /// The logger of the session. /// Cancels the release. @@ -43,12 +52,20 @@ public static async ValueTask ReleaseAsync( { try { + long started = Stopwatch.GetTimestamp(); + await provider.ReleaseSessionAsync(session, cancelRunning: true, ReleaseTimeout, cancellationToken) .ConfigureAwait(false); + + // MAF gives up on a child that ignores its cancel without saying so; the full wait is the only sign. + if (Stopwatch.GetElapsedTime(started) >= ReleaseTimeout - TimerSlack) + { + SessionOwnerLog.BackgroundReleaseTimedOut(logger, conversationId, ReleaseTimeout.TotalSeconds); + } } catch (Exception exception) when (exception is not OperationCanceledException) { - Log.BackgroundReleaseFailed(logger, conversationId, exception); + SessionOwnerLog.BackgroundReleaseFailed(logger, conversationId, exception); } } } diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs b/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs index a54b1445..108ab96d 100644 --- a/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs +++ b/src/AgentCore.Application/Runtime/Harness/ConversationFilesProvider.cs @@ -3,20 +3,9 @@ namespace AgentCore.Application.Runtime.Harness { - // The store behind an agent's files: block, bound per invocation: the framework's - // own file-access tools do the work, rooted at the running conversation's workspace folder. A - // shared store cannot serve every conversation — the overrides take no session — so this builds - // the framework provider per conversation around the same options, exactly like the prefetch - // search provider. State keys and post-invocation storage forward to the shared template: - // accumulation lives in the session store, not on any instance. - #pragma warning disable MAAI001 // File-store types are evaluation-only in Microsoft.Agents.AI 1.21.0. - internal sealed class ConversationFilesProvider : AIContextProvider, IDisposable { - private const string NoTurnMessage = - "A files: tool runs only while a turn runs through a ConversationSession with a workspace root bound."; - private readonly FileAccessProvider _template; private readonly FileAccessProviderOptions _options; @@ -47,12 +36,11 @@ public ConversationFilesProvider(string root, FileAccessProviderOptions options) protected override async ValueTask InvokingCoreAsync( InvokingContext context, CancellationToken cancellationToken = default) { - string workspace = TurnRegistry.For(context.Session)?.Workspace - ?? throw new InvalidOperationException(NoTurnMessage); + if (TurnRegistry.For(context.Session)?.Workspace is not { } workspace) + { + return new AIContext(); + } - // Not using-declared on purpose: the tools this returns run after this method does, - // and disposing the provider would dispose the store out from under them. The store - // holds a path, not handles, so ordinary collection reclaims it. FileAccessProvider inner = new(new FileSystemAgentFileStore(workspace), _options); return await inner.InvokingAsync(context, cancellationToken).ConfigureAwait(false); diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs b/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs index e24a1a31..a9752ec1 100644 --- a/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs +++ b/src/AgentCore.Application/Runtime/Harness/ConversationShellEnvironmentProvider.cs @@ -5,16 +5,10 @@ namespace AgentCore.Application.Runtime.Harness { /// - /// The instructions behind an agent's shell: block: the shell family, version, working - /// directory and CLI versions, probed once per call through that conversation's executor and rendered - /// with MAF's own formatter. No StateKeys — the snapshot lives on - /// and dies with the conversation, so resume re-probes a fresh environment. + /// The instructions behind an agent's shell: block. /// internal sealed class ConversationShellEnvironmentProvider : AIContextProvider { - private const string NoTurnMessage = - "A shell: tool runs only while a turn runs through a ConversationSession with a workspace root bound."; - private readonly ConversationShellOptions _options; public ConversationShellEnvironmentProvider(ConversationShellOptions options) @@ -26,8 +20,10 @@ public ConversationShellEnvironmentProvider(ConversationShellOptions options) protected override async ValueTask ProvideAIContextAsync( InvokingContext context, CancellationToken cancellationToken = default) { - ConversationShells shells = TurnRegistry.For(context.Session)?.Shells - ?? throw new InvalidOperationException(NoTurnMessage); + if (TurnRegistry.For(context.Session)?.Shells is not { } shells) + { + return new AIContext(); + } ShellEnvironmentSnapshot snapshot = await shells.GetEnvironmentAsync(_options, cancellationToken).ConfigureAwait(false); diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs b/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs index 3dc41bd3..24308de1 100644 --- a/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs +++ b/src/AgentCore.Application/Runtime/Harness/ConversationShellProvider.cs @@ -4,16 +4,10 @@ namespace AgentCore.Application.Runtime.Harness { /// - /// The context provider behind an agent's shell: block: on every invocation it hands the - /// model this conversation's shell executor as MAF's own tool. No instructions of our own — MAF's tool - /// description is what the model reads — and no StateKeys, because the shell keeps no - /// session state. + /// The context provider behind an agent's shell: block. /// internal sealed class ConversationShellProvider : AIContextProvider { - private const string NoTurnMessage = - "A shell: tool runs only while a turn runs through a ConversationSession with a workspace root bound."; - private readonly ConversationShellOptions _options; public ConversationShellProvider(ConversationShellOptions options) @@ -22,16 +16,16 @@ public ConversationShellProvider(ConversationShellOptions options) _options = options; } + // A delegated agent's run files no turn on its own session, so the shell has no workspace to + // run in. The tool is simply absent rather than a thrown fault (design §6.3#6). protected override ValueTask ProvideAIContextAsync( InvokingContext context, CancellationToken cancellationToken = default) { - ConversationShells shells = TurnRegistry.For(context.Session)?.Shells - ?? throw new InvalidOperationException(NoTurnMessage); + ConversationShells? shells = TurnRegistry.For(context.Session)?.Shells; - return new ValueTask(new AIContext - { - Tools = [shells.Get(_options).AsAIFunction(requireApproval: false)], - }); + return new ValueTask(shells is null + ? new AIContext() + : new AIContext { Tools = [shells.Get(_options).AsAIFunction(requireApproval: false)] }); } } } diff --git a/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs b/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs index 547d6865..5e8e9a24 100644 --- a/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs +++ b/src/AgentCore.Application/Runtime/Harness/ConversationShells.cs @@ -228,7 +228,7 @@ private ShellExecutor Create(ConversationShellOptions options) /// /// Disposes every executor this conversation started. Idempotent, and never throws out: a failed - /// dispose is logged at Warning through instead. A second + /// dispose is logged at Warning through instead. A second /// concurrent caller returns immediately rather than wait for the first dispose to finish — the /// executors are only ever taken out and disposed once. /// @@ -258,7 +258,7 @@ public async ValueTask DisposeAsync() { if (_logger is not null) { - Log.ShellDisposeFailed(_logger, _workspace, exception); + SessionOwnerLog.ShellDisposeFailed(_logger, _workspace, exception); } } } diff --git a/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs b/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs index 926d560f..8c55f6a5 100644 --- a/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs +++ b/src/AgentCore.Application/Runtime/Harness/HarnessSessionState.cs @@ -12,6 +12,8 @@ namespace AgentCore.Application.Runtime.Harness /// internal static class HarnessSessionState { + private const string StateBagKey = "stateBag"; + private static readonly IReadOnlyDictionary Empty = ReadOnlyDictionary.Empty; @@ -50,6 +52,13 @@ public static IReadOnlyDictionary Capture(AgentSession? ses /// { key: element, ... } }, with no other member. /// public static JsonElement Wrap(IReadOnlyDictionary providers) + { + JsonObject envelope = new() { [StateBagKey] = BagOf(providers) }; + + return JsonSerializer.SerializeToElement(envelope); + } + + private static JsonObject BagOf(IReadOnlyDictionary providers) { JsonObject stateBag = []; @@ -58,9 +67,7 @@ public static JsonElement Wrap(IReadOnlyDictionary provider stateBag[key] = JsonSerializer.SerializeToNode(element); } - JsonObject envelope = new() { ["stateBag"] = stateBag }; - - return JsonSerializer.SerializeToElement(envelope); + return stateBag; } } } diff --git a/src/AgentCore.Application/Runtime/ITurnCompleter.cs b/src/AgentCore.Application/Runtime/ITurnCompleter.cs new file mode 100644 index 00000000..328b1f65 --- /dev/null +++ b/src/AgentCore.Application/Runtime/ITurnCompleter.cs @@ -0,0 +1,44 @@ +using AgentCore.Application.Transcript; + +namespace AgentCore.Application.Runtime +{ + /// + /// The conversation's side of one turn's seal, which calls in a fixed + /// order: , then the provider's commit under the turn lock, then + /// , then under the turn lock and . + /// A turn the store refused stops after . + /// + internal interface ITurnCompleter + { + /// Reports that the turn's edit withdrew earlier turns, before the run starts. + /// The turns the edit took. + void Superseded(WithdrawnTurns withdrawn); + + /// + /// Decides the turn after its run: the tool-result writers, the extractor, the clock, the counters and the + /// stage advance. + /// + /// The turn as the layer saw it end. + /// The fault the run threw, or . + /// completed with the state and the line the record holds. + ValueTask CompleteAsync(TurnCommit sealing, Exception? fault); + + /// + /// Waits for the store's answer to the commit, and closes the turn when the store refused it because another + /// session of the conversation saved the same turn first. + /// + /// Completes with when the store refused the commit. + /// when the turn was refused, and publishes nothing. + ValueTask RefusedAsync(Task refused); + + /// Raises the turn's events once its words are written. Runs under the turn lock. + /// What the commit wrote the user's message and the last message under, or . + /// The words the turn's rows say (), which the turn's hashes prove. + /// A cut that arrived after the seal and before the commit, or . + /// A recut of the turn's own cut that arrived after the seal and before the commit, or . + void Committed((string UserMessageId, string? ReplyMessageId)? ids, string spoken, TurnCut? late, TurnCut? recut); + + /// Closes the turn after the lock: its telemetry, and the conversation when it is over. + ValueTask FinishAsync(); + } +} diff --git a/src/AgentCore.Application/Runtime/ModerationAgent.cs b/src/AgentCore.Application/Runtime/ModerationAgent.cs index d53f8c75..e222cce3 100644 --- a/src/AgentCore.Application/Runtime/ModerationAgent.cs +++ b/src/AgentCore.Application/Runtime/ModerationAgent.cs @@ -14,7 +14,9 @@ internal sealed class ModerationAgent : DelegatingAIAgent internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2); private readonly PromptModerator _moderator; + private readonly string _refusalReply; + private readonly TimeSpan _timeout; /// Puts moderation in front of one turn agent. @@ -85,10 +87,6 @@ private async IAsyncEnumerable RunCoreStreamingCoreAsync( yield break; } - // A leading update with EMPTY contents. Empty contents contribute no text, so the caller's - // audio is untouched and the turn seam drops the update before the host ever sees it. - // Update-level properties do not survive streaming coalescing, which is why the marker rides - // an update of its own rather than the assistant message the updates fold into. AgentResponseUpdate marker = new() { Role = ChatRole.Assistant }; Attach(marker.AdditionalProperties ??= [], verdict); yield return marker; @@ -170,7 +168,7 @@ private static void Attach(AdditionalPropertiesDictionary properties, TurnDispos { if (properties.TryGetValue(out TurnDisposition? existing) && existing is not null) { - verdict = verdict with { Fallback = existing.Fallback, FallbackReason = existing.FallbackReason }; + verdict = verdict with { Fallback = existing.Fallback, FallbackFault = existing.FallbackFault }; _ = properties.Remove(); } diff --git a/src/AgentCore.Application/Runtime/ReplyOutcome.cs b/src/AgentCore.Application/Runtime/ReplyOutcome.cs index de95e5d2..824086c1 100644 --- a/src/AgentCore.Application/Runtime/ReplyOutcome.cs +++ b/src/AgentCore.Application/Runtime/ReplyOutcome.cs @@ -4,16 +4,30 @@ namespace AgentCore.Application.Runtime { /// What the turn said, what the caller heard, and why they differ if they do. /// The text the record holds: what the caller heard. - /// The text the model produced, before any barge-in cut it. + /// + /// The text the model produced across every step, before any cut. Only a cut turn's turn.completed proves it: + /// every other turn proves the words its rows say. + /// /// The section 8.7 reason, or . - /// The row-six fault, from the run or from the fallback layer. + /// The message of the fault a tool let out (row six), or when no tool did. /// The played duration of a cut reply, or . /// The tool calls still waiting on a human answer. + /// + /// The fault that ended the run, live and not flattened to a message: the Error log needs the object, for its + /// stack trace. + /// + /// + /// Whether a tool call let out (): a tool that spent its retry + /// budget. Otherwise the run faulted outside every tool, say because the model endpoint did not answer, whether + /// caught it or it reached the turn from above that layer. + /// internal sealed record ReplyOutcome( string Reply, - string SpokenReply, + string GeneratedText, string? Failure, string? ToolFault, TimeSpan? InterruptedAfter, - List Approvals); + List Approvals, + Exception? Fault = null, + bool IsToolFault = false); } diff --git a/src/AgentCore.Application/Runtime/ReplyText.cs b/src/AgentCore.Application/Runtime/ReplyText.cs new file mode 100644 index 00000000..42c2539a --- /dev/null +++ b/src/AgentCore.Application/Runtime/ReplyText.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime +{ + /// + /// Picks the one message of a run that the caller actually hears. + /// + internal static class ReplyText + { + /// Reads the words this run puts in the caller's ear. + /// The messages of the run, oldest first. + /// + /// The agents.items ids whose reply the caller hears, or when every + /// message counts. CompiledAgent.OutputAgents names them. + /// + /// The spoken text, or an empty string when the run produced none. + internal static string From(IList messages, IReadOnlySet? outputAgents = null) + { + for (int index = messages.Count - 1; index >= 0; index--) + { + ChatMessage message = messages[index]; + + if (message.Contents.Count == 0) + { + continue; + } + + if (outputAgents is not null && (message.AuthorName is not { } author || !outputAgents.Contains(author))) + { + continue; + } + + return message.Text; + } + + return string.Empty; + } + } +} diff --git a/src/AgentCore.Application/Runtime/SpokenReply.cs b/src/AgentCore.Application/Runtime/SpokenReply.cs deleted file mode 100644 index 9b032997..00000000 --- a/src/AgentCore.Application/Runtime/SpokenReply.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace AgentCore.Application.Runtime -{ - /// - /// Picks the one message of a run that the caller actually hears. - /// - /// - /// - /// AgentResponse.Text concatenates every message of the response. On a graph row that is every - /// node's reply, so the caller would hear the graph deliberating. The last message is the spoken one - /// — but only after two artefacts are stepped over, and both were measured on Microsoft.Agents.AI - /// 1.17.0. - /// - /// - /// A folded stream is mostly lifecycle. Collecting a two-node graph's updates and calling - /// ToAgentResponse() gave 25 messages for two replies: 23 of them carried no content at all, - /// and ten of those sat after the last spoken word. Reading the last message flatly would therefore - /// report every streaming graph turn as an empty reply. - /// - /// - /// A quiet node vanishes when buffered. A sequential graph whose last node answers with - /// whitespace returns one message, and it is the node BEFORE it. Naming the agents the caller hears - /// is what keeps that deliberation out of the caller's ear. - /// - /// - internal static class SpokenReply - { - /// Reads the words this run puts in the caller's ear. - /// The messages of the run, oldest first. - /// - /// The agents.items ids whose reply the caller hears, or when every - /// message counts. CompiledAgent.SpokenBy names them. - /// - /// The spoken text, or an empty string when the run produced none. - internal static string From(IList messages, IReadOnlySet? spokenBy = null) - { - for (int index = messages.Count - 1; index >= 0; index--) - { - ChatMessage message = messages[index]; - - // A message with no content at all is a workflow lifecycle event, not speech. A message - // that carries content and no words is a reply that said nothing, which IS the failure. - if (message.Contents.Count == 0) - { - continue; - } - - if (spokenBy is not null && (message.AuthorName is not { } author || !spokenBy.Contains(author))) - { - continue; - } - - return message.Text; - } - - return string.Empty; - } - } -} diff --git a/src/AgentCore.Application/Runtime/ToolFaultMark.cs b/src/AgentCore.Application/Runtime/ToolFaultMark.cs new file mode 100644 index 00000000..d7202d06 --- /dev/null +++ b/src/AgentCore.Application/Runtime/ToolFaultMark.cs @@ -0,0 +1,38 @@ +namespace AgentCore.Application.Runtime +{ + /// + /// Marks a fault a tool threw, so the turn can tell a tool that spent its retry budget from a run that faulted + /// outside every tool. catches both, and a model endpoint that did not answer throws + /// the same exception types a tool's endpoint does. + /// + internal static class ToolFaultMark + { + private const string Key = "AgentCore.ToolCallId"; + + /// Marks a fault one tool call let out. + /// What the tool threw. + /// The id the model gave the call. + internal static void Put(Exception fault, string toolCallId) + { + ArgumentNullException.ThrowIfNull(fault); + + fault.Data[Key] = toolCallId; + } + + /// Reads whether a tool threw this fault, or a fault it wraps. + /// The fault that ended the run. + /// when a tool call let it out. + internal static bool IsOn(Exception fault) + { + ArgumentNullException.ThrowIfNull(fault); + + return fault switch + { + _ when fault.Data.Contains(Key) => true, + AggregateException aggregate => aggregate.InnerExceptions.Any(IsOn), + { InnerException: { } inner } => IsOn(inner), + _ => false, + }; + } + } +} diff --git a/src/AgentCore.Application/Runtime/TranscriptEntry.cs b/src/AgentCore.Application/Runtime/TranscriptEntry.cs deleted file mode 100644 index e13e6b37..00000000 --- a/src/AgentCore.Application/Runtime/TranscriptEntry.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace AgentCore.Application.Runtime -{ - /// What this turn adds to the transcript. - /// The assistant message the caller heard, or when nothing was. - /// Every message store 0 appends after the caller's own; empty for rows 3 and 4. - internal sealed record TranscriptEntry(ChatMessage? Heard, List Written); -} diff --git a/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs b/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs new file mode 100644 index 00000000..98467102 --- /dev/null +++ b/src/AgentCore.Application/Runtime/Turn/TurnCommittedContent.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime.Turn +{ + /// + /// The ids a turn's commit wrote, on the last update of the turn. A streaming caller never sees the ids any + /// other way, because the framework's hook runs after the last update (design probes P08b, G3). + /// + /// What the user's message was written under. + /// What the last message was written under, or when the user's is the only one. + internal sealed class TurnCommittedContent(string userMessageId, string? replyMessageId) : AIContent + { + /// Gets what the user's message was written under. + public string UserMessageId { get; } = userMessageId; + + /// Gets what the last message was written under, or when the user's is the only one. + public string? ReplyMessageId { get; } = replyMessageId; + } +} diff --git a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs index 0ba9b38f..f6764d9e 100644 --- a/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs +++ b/src/AgentCore.Application/Runtime/Turn/TurnMessages.cs @@ -16,7 +16,7 @@ internal static class TurnMessages /// /// What names the caller in that message. /// - internal const string CallerLinePrefix = "Caller: "; + internal const string UserLinePrefix = "User: "; /// /// What names this agent in that message. @@ -40,7 +40,7 @@ internal static class TurnMessages } _ = rendered - .Append(message.Role == ChatRole.User ? CallerLinePrefix : AgentLinePrefix) + .Append(message.Role == ChatRole.User ? UserLinePrefix : AgentLinePrefix) .Append(text) .Append('\n'); } @@ -58,10 +58,10 @@ internal static bool CarriesContent(ChatResponseUpdate update) return update.Contents.Any(content => content is not TextContent text || text.Text.Length > 0); } - /// Keeps the tool calls whose results arrived, and drops every unpaired call and every word. + /// Keeps the words and the tool calls whose results arrived, and drops every unpaired call. /// Every message the round produced. - /// The tool content that carries a complete call-and-result pair, in its original order. - internal static List FinishedToolMessages(IList messages) + /// The words and the complete call-and-result pairs, in their original order. + internal static List FinishedMessages(IList messages) { HashSet answered = []; foreach (ChatMessage message in messages) @@ -82,16 +82,16 @@ internal static List FinishedToolMessages(IList messag [ .. message.Contents.Where(content => content switch { - TextContent => false, FunctionCallContent call => answered.Contains(call.CallId), _ => true, }), ]; - if (!tools.Any(content => content is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent)) + if (!tools.Any(content => content is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent + || content is TextContent { Text.Length: > 0 })) { - // Plain prose, or a message whose every call is still in flight. Neither belongs in - // the next turn. + // A message whose every call is still in flight, with no words beside them. It does not + // belong in the next turn. continue; } @@ -110,5 +110,20 @@ internal static List FinishedToolMessages(IList messag return kept; } + + /// + /// The whole reply a turn's run produced, every step's text in order. + /// + /// Every message the round produced. + /// + /// Every step's text, concatenated with no separator between steps — the same rule + /// reconstructs a cut reply's shown text against, so a + /// multi-step reply's hash and its transcript rows never disagree over where one step ends and + /// the next begins. + /// + internal static string AllText(IList messages) + { + return string.Concat(FinishedMessages(messages).Select(message => message.Text)); + } } } diff --git a/src/AgentCore.Application/Runtime/TurnCut.cs b/src/AgentCore.Application/Runtime/TurnCut.cs new file mode 100644 index 00000000..5afe2fdd --- /dev/null +++ b/src/AgentCore.Application/Runtime/TurnCut.cs @@ -0,0 +1,9 @@ +namespace AgentCore.Application.Runtime +{ + /// What the user saw or heard of a reply before it was cut. + /// + /// The text that reached the user, or for everything the engine yielded. + /// + /// How much of the reply played, when a voice layer knows it. + public sealed record TurnCut(string? ShownText, TimeSpan? Played); +} diff --git a/src/AgentCore.Application/Runtime/TurnCutSlot.cs b/src/AgentCore.Application/Runtime/TurnCutSlot.cs new file mode 100644 index 00000000..dad1cf6a --- /dev/null +++ b/src/AgentCore.Application/Runtime/TurnCutSlot.cs @@ -0,0 +1,146 @@ +namespace AgentCore.Application.Runtime +{ + /// + /// Where a cut of one running turn waits for the seal, and the turn lock that linearizes the cut against + /// the turn's commit (design section 3, "Command"). + /// + /// The conversation's turn lock. + /// The host's token, which the run's own token is linked to. + internal sealed class TurnCutSlot(Lock turnLock, CancellationToken cancellationToken) : IDisposable + { + private readonly CancellationTokenSource _run = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + private TurnCut? _cut; + + private TurnCut? _late; + + private TurnCut? _lateRecut; + + private bool _sealed; + + private bool _committed; + + /// Gets the turn lock. The commit of the turn runs under it. + public Lock Gate { get; } = turnLock; + + /// Gets the token the run reads. A cut before the seal cancels it. + public CancellationToken Token => _run.Token; + + /// Gets whether a cut reached the turn before its seal. + public bool IsCut + { + get + { + lock (Gate) + { + return _cut is not null; + } + } + } + + /// Gets whether the turn's words are committed, so a cut from now on amends them. + public bool IsCommitted + { + get + { + lock (Gate) + { + return _committed; + } + } + } + + /// Records a cut of this turn. + /// What the user saw or heard. + /// when the turn already committed, so the cut belongs to the committed turn. + public bool TryCut(TurnCut cut) + { + ArgumentNullException.ThrowIfNull(cut); + + lock (Gate) + { + if (_committed) + { + return false; + } + + if (_sealed) + { + _late = cut; + return true; + } + + _cut = cut; + _run.Cancel(); + return true; + } + } + + /// Replaces the cut this turn already took. + /// A later account of what the user saw or heard. + /// + /// when the replacement was recorded; when the turn took no cut + /// before its seal, or already committed, so the recut belongs to the committed turn. + /// + public bool TryRecut(TurnCut cut) + { + ArgumentNullException.ThrowIfNull(cut); + + lock (Gate) + { + if (_committed || _cut is null) + { + return false; + } + + if (_sealed) + { + _lateRecut = cut; + } + else + { + _cut = cut; + } + + return true; + } + } + + /// Seals the turn, once: a cut from now on waits for the commit. + /// The cut that stopped the run, or when none did. + /// when the turn was already sealed. + public bool TrySeal(out TurnCut? cut) + { + lock (Gate) + { + cut = _cut; + if (_sealed) + { + return false; + } + + _sealed = true; + return true; + } + } + + /// Marks the turn committed. The caller holds across the commit. + /// The recut that arrived between the seal and the commit, or . + /// The cut that arrived between the seal and the commit, or . + public TurnCut? Commit(out TurnCut? recut) + { + lock (Gate) + { + _committed = true; + recut = _lateRecut; + return _late; + } + } + + /// + public void Dispose() + { + _run.Dispose(); + } + } +} diff --git a/src/AgentCore.Application/Runtime/TurnDisposition.cs b/src/AgentCore.Application/Runtime/TurnDisposition.cs index 44537a19..ba7ab3d1 100644 --- a/src/AgentCore.Application/Runtime/TurnDisposition.cs +++ b/src/AgentCore.Application/Runtime/TurnDisposition.cs @@ -37,29 +37,20 @@ internal enum FallbackCause /// The moderation endpoint's order, unmodified, or when nothing was flagged. /// /// Why the fallback layer spoke, or . - /// - /// The message of the fault the fallback layer caught, or . The turn's - /// tool.failed row carries this text, so it is the exception's message and not its type name. + /// + /// The exception the fallback layer caught, or . Kept live and not flattened to a + /// message here: the turn's tool.failed row needs the message, and the span and the Error log + /// each need a different cut of it (type only, and the full object), so the decision of which text goes + /// where stays with the turn loop, not with this layer. /// /// /// Why the endpoint did not answer, in the words the turn loop logs, or . It /// is set only when is . /// - /// - /// - /// Carried on when buffered, and on a leading - /// when streaming — update-level properties do not survive - /// streaming coalescing, so the streaming reader must take it off the update, not the folded run. - /// - /// - /// It is attached and read with , which keys on the full - /// type name, so nothing else in the pipeline can collide with it. - /// - /// internal sealed record TurnDisposition( ModerationOutcome? Moderation, string? FlaggedCategories, FallbackCause Fallback, - string? FallbackReason, + Exception? FallbackFault, string? ModerationReason); } diff --git a/src/AgentCore.Application/Runtime/TurnInvocation.cs b/src/AgentCore.Application/Runtime/TurnInvocation.cs index 0c33e1d6..7073a9a9 100644 --- a/src/AgentCore.Application/Runtime/TurnInvocation.cs +++ b/src/AgentCore.Application/Runtime/TurnInvocation.cs @@ -98,6 +98,30 @@ internal IReadOnlyList Attachments() /// public StateDocument? State { get; init; } + /// + /// Gets the session the conversation's words live on, or outside a conversation turn. + /// On a graph row it is not the session the run gets. + /// + public AgentSession? HistorySession { get; init; } + + /// Gets the message the user sent this turn, or outside a conversation turn. + public ChatMessage? User { get; init; } + + /// Gets where the turn hangs in the conversation the user can see, or when the caller did not say. + public ConversationTurnOrigin? Origin { get; init; } + + /// + /// Gets whether the conversation so far rides the request as one system message, as on a graph row that + /// runs every turn on a fresh session. + /// + public bool RendersHistory { get; init; } + + /// Gets the conversation's side of the turn's seal, or outside a conversation turn. + public ITurnCompleter? Completer { get; init; } + + /// Gets where a cut of the running turn waits for the seal, or outside a conversation turn. + public TurnCutSlot? CutSlot { get; init; } + /// Reads the turn filed on a run's options by , or null when there is none. internal static TurnInvocation? From(AgentRunOptions? options) { diff --git a/src/AgentCore.Application/Runtime/TurnRefusals.cs b/src/AgentCore.Application/Runtime/TurnRefusals.cs new file mode 100644 index 00000000..38603efd --- /dev/null +++ b/src/AgentCore.Application/Runtime/TurnRefusals.cs @@ -0,0 +1,47 @@ +using AgentCore.Domain.Audit; + +namespace AgentCore.Application.Runtime +{ + /// + /// Raises turn.refused for a turn that keeps none of its words, so the refusal leaves a log line and an audit + /// row even when the client that could read the error is gone. + /// + internal static class TurnRefusals + { + /// Another turn held the conversation past . + internal const string Busy = "busy"; + + /// The store refused the turn's words: another session saved that turn first. + internal const string Conflict = "conflict"; + + /// The client left while the turn waited for the conversation. + internal const string Gone = "gone"; + + /// The caller disposed the turn's run before it read the reply, so the model never ran. + internal const string Dropped = "dropped"; + + /// Raises the refusal, unless the conversation's chain already closed. + /// The conversation. + /// + /// The turn the session ran, or would have run, or for a turn refused before it read + /// the conversation: which index it would have taken is not known then. + /// + /// One of the tokens above. + internal static void Raise(ConversationSession session, int? turnIndex, string reason) + { + if (session.Events.HasEnded) + { + return; + } + + _ = session.Events.Raise( + ConversationEventKind.TurnRefused, + session.Time.GetUtcNow(), + turnIndex, + payload: new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.RefusedReason] = reason, + }); + } + } +} diff --git a/src/AgentCore.Application/Runtime/TurnReplyResolution.cs b/src/AgentCore.Application/Runtime/TurnReplyResolution.cs new file mode 100644 index 00000000..e705b593 --- /dev/null +++ b/src/AgentCore.Application/Runtime/TurnReplyResolution.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Domain; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime +{ + /// Decides what one turn said, and why, from how its run ended. + internal static class TurnReplyResolution + { + /// Reads the reply, the failure, and the approvals still pending of one finished turn. + /// The conversation the turn belongs to. + /// The turn that just ran. + /// What the agent answered. + /// The cut the turn was sealed with, or . + /// What the pipeline layers reported, or . + /// The fault that threw out of the run, or . + /// Whether the turn keeps only the one line the user was shown, as a graph row does. + /// What the turn said, what the caller heard, and why they differ if they do. + internal static ReplyOutcome Resolve( + ConversationSession session, + ConversationTurn turn, + AgentResponse response, + TurnCut? interruption, + TurnDisposition? disposition, + Exception? fault, + bool callerFacing) + { + if (fault is null && disposition is { Fallback: FallbackCause.Faulted, FallbackFault: { } caught }) + { + fault = caught; + } + + bool isToolFault = fault is not null && ToolFaultMark.IsOn(fault); + + string reply = ReplyText.From(response.Messages); + + string generatedText = callerFacing ? reply : TurnMessages.AllText(response.Messages); + + TimeSpan? interruptedAfter = null; + + string? toolFault = isToolFault ? fault!.Message : null; + + string? failure = fault is null + ? null + : (isToolFault ? ConversationSession.ToolFailureReason : ConversationSession.RunFaultReason) + + " " + fault.Message; + + List approvals = ApprovalRequestsOf(response); + + if (interruption is { } cut) + { + reply = (cut.ShownText ?? generatedText).Trim(); + interruptedAfter = cut.Played ?? TimeSpan.Zero; + } + else if (approvals.Count == 0 && (failure is not null + || string.IsNullOrWhiteSpace(reply) + || disposition?.Fallback is FallbackCause.EmptyReply)) + { + failure ??= ConversationSession.EmptyReplyReason; + reply = session.Compiled.FallbackReply; + } + + if (fault is null && failure is not null) + { + session.Events.RaiseDiagnostic(ConversationEventKind.EmptyReply, session.Time.GetUtcNow(), turn.Index); + } + else if (fault is not null && !isToolFault) + { + Log.TurnRunFaulted(session.Logger, session.ConversationId, turn.Index, fault); + session.Events.RaiseDiagnostic(ConversationEventKind.RunFaulted, session.Time.GetUtcNow(), turn.Index); + } + + return new ReplyOutcome(reply, generatedText, failure, toolFault, interruptedAfter, approvals, fault, isToolFault); + } + + /// Reads the approval requests one finished turn still waits on. + /// What the agent answered. + /// One entry per request content, oldest first; empty when the turn asked nothing. + private static List ApprovalRequestsOf(AgentResponse response) + { + List approvals = []; + foreach (ToolApprovalRequestContent request in response.Messages + .SelectMany(message => message.Contents) + .OfType()) + { + if (request.ToolCall is not FunctionCallContent call) + { + continue; + } + + approvals.Add(new PendingApproval( + request.RequestId, + call.Name, + JsonSerializer.SerializeToElement( + call.Arguments ?? new Dictionary(StringComparer.Ordinal)))); + } + + return approvals; + } + } +} diff --git a/src/AgentCore.Application/Runtime/TurnRun.cs b/src/AgentCore.Application/Runtime/TurnRun.cs new file mode 100644 index 00000000..e1a38886 --- /dev/null +++ b/src/AgentCore.Application/Runtime/TurnRun.cs @@ -0,0 +1,110 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Runtime +{ + /// One turn that has started: its index, and its reply as it arrives. + public sealed class TurnRun : IAsyncDisposable + { + private const int Unread = 0; + + private const int Reading = 1; + + private const int Disposed = 2; + + private readonly IAsyncEnumerable _updates; + + private readonly Action? _abandon; + + private readonly Lock _gate = new(); + + private IAsyncEnumerator? _reader; + + private int _state; + + /// Creates a run over a reply the caller already has. + /// The index of the turn, which Cut names. + /// The reply, one update at a time. + /// is . + public TurnRun(int turnIndex, IAsyncEnumerable updates) + : this(turnIndex, updates, abandon: null) + { + } + + /// The index of the turn. + /// The reply. Its enumeration holds the conversation, and ends the turn in a finally. + /// Ends a turn whose reply was never read, or when there is nothing to end. + internal TurnRun(int turnIndex, IAsyncEnumerable updates, Action? abandon) + { + ArgumentNullException.ThrowIfNull(updates); + + TurnIndex = turnIndex; + _updates = updates; + _abandon = abandon; + } + + /// Gets the index of the turn, which Cut names. + public int TurnIndex { get; } + + /// + /// Gets the reply, one update at a time. Every update carries content. Enumerate it once: the turn + /// commits only as the enumeration runs and ends. + /// + public IAsyncEnumerable Updates => ReadAsync(); + + /// Frees the conversation this run holds. Safe to call more than once. + /// Call it from the flow that reads , not while a read is pending. + public async ValueTask DisposeAsync() + { + int before; + IAsyncEnumerator? reader; + + lock (_gate) + { + before = _state; + reader = _reader; + _state = Disposed; + } + + if (before == Unread) + { + _abandon?.Invoke(); + } + else if (before == Reading && reader is not null) + { + await reader.DisposeAsync().ConfigureAwait(false); + } + } + + private async IAsyncEnumerable ReadAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + IAsyncEnumerator reader; + + lock (_gate) + { + if (_state != Unread) + { + throw new InvalidOperationException(_state == Disposed + ? $"The run of turn {TurnIndex} was disposed, so its reply can no longer be read." + : $"The reply of turn {TurnIndex} is already being read."); + } + + reader = _updates.GetAsyncEnumerator(cancellationToken); + _reader = reader; + _state = Reading; + } + + try + { + while (await reader.MoveNextAsync().ConfigureAwait(false)) + { + yield return reader.Current; + } + } + finally + { + await reader.DisposeAsync().ConfigureAwait(false); + } + } + } +} diff --git a/src/AgentCore.Application/Sessions/Memory/HeldSession.cs b/src/AgentCore.Application/Sessions/Memory/HeldSession.cs new file mode 100644 index 00000000..28de9921 --- /dev/null +++ b/src/AgentCore.Application/Sessions/Memory/HeldSession.cs @@ -0,0 +1,289 @@ +using AgentCore.Application.Runtime; + +namespace AgentCore.Application.Sessions.Memory +{ + /// One session that holds, with the idle timer that unloads it. + internal sealed class HeldSession : IDisposable + { + /// + /// How often the idle check polls for a background: child, before it is clamped to the idle + /// timeout in the constructor (a poll longer than the timeout it serves would never fire in time). + /// + internal static readonly TimeSpan BackgroundChildPollInterval = TimeSpan.FromSeconds(30); + + private readonly Lock _gate = new(); + + private readonly TimeSpan _idleTimeout; + + private readonly TimeSpan _backgroundPollInterval; + + private readonly TimeProvider _time; + + private readonly Action _expired; + + private readonly ITimer _timer; + + // A GetTimestamp value, not a DateTimeOffset, so that a wall-clock jump neither expires nor keeps a session. + private long _touched; + + // Set when the timer is gone. The session is still handed out until it is also ended. + private bool _stopped; + + // Set once the session expired or the host closed it: its close has begun. Nothing may hand it out after that. + private bool _ended; + + // The running turn OnIdle last saw holding the session past its idle timeout. Tracked so the completion + // continuation that restarts the idle clock is attached once per turn, not once per poll. + private Task? _turnHeartbeat; + + /// Holds one session and arms its idle timer. + /// The session to hold. + /// How long the session may stay untouched. + /// The clock the timer runs on. + /// Called once, off the gate, when the session expired. + internal HeldSession(ConversationSession session, TimeSpan idleTimeout, TimeProvider time, Action expired) + { + Session = session; + _idleTimeout = idleTimeout; + _backgroundPollInterval = idleTimeout < BackgroundChildPollInterval ? idleTimeout : BackgroundChildPollInterval; + _time = time; + _expired = expired; + + lock (_gate) + { + _touched = time.GetTimestamp(); + _timer = CreateTimer(); + } + } + + /// Gets the session held. + internal ConversationSession Session { get; } + + /// + /// Restarts the idle clock, and stamps the workspace folder to match: every place that restarts one + /// restarts the other, so "held" always implies "stamped within the idle timeout" to another server + /// sharing the workspace root. + /// + /// once the session has ended. The caller must not hand it out then. + internal bool TryTouch() + { + bool restarted = false; + + lock (_gate) + { + if (_ended) + { + return false; + } + + if (!_stopped) + { + _touched = _time.GetTimestamp(); + _ = _timer.Change(NextDueLocked(), Timeout.InfiniteTimeSpan); + restarted = true; + } + } + + if (restarted) + { + Session.Lifetime.TouchWorkspace(); + } + + return true; + } + + /// Gets whether the session's close has begun, from its idle timer or from the host. + internal bool IsEnded + { + get + { + lock (_gate) + { + return _ended; + } + } + } + + /// Ends the session because the host closes it. Its timer never fires after this. + /// + /// when the session had already ended, and whoever ended it runs the close. + /// + internal bool TryEnd() + { + lock (_gate) + { + if (_ended) + { + return false; + } + + _ended = true; + StopLocked(); + return true; + } + } + + /// Stops the timer. The session stays held, and a lookup still finds it. + public void Dispose() + { + lock (_gate) + { + StopLocked(); + } + } + + private void OnIdle() + { + Task? turn = null; + Task? attachHeartbeatTo = null; + bool stopped = false; + bool rescheduled = false; + bool touchWorkspace = false; + + lock (_gate) + { + if (_stopped) + { + stopped = true; + } + else + { + bool pollBackground = Session.Lifetime.MayHaveBackgroundChildren; + + // A touch can re-arm the timer after it came due but before this callback ran. + TimeSpan idle = _time.GetElapsedTime(_touched); + if (idle < _idleTimeout) + { + // A background-capable document is watched the whole window through, at the shorter + // cadence, rather than only once the window is up: a child running right now is + // activity, read exactly like a touch, so the window does not close under it later for + // having missed it. + if (pollBackground && Session.Lifetime.HasRunningBackgroundChild()) + { + _touched = _time.GetTimestamp(); + touchWorkspace = true; + } + + _ = _timer.Change(pollBackground ? _backgroundPollInterval : _idleTimeout - idle, Timeout.InfiniteTimeSpan); + rescheduled = true; + } + else + { + turn = Session.Cuts.RunningTurn(); + if (turn is null) + { + _turnHeartbeat = null; + + if (pollBackground && Session.Lifetime.HasRunningBackgroundChild()) + { + // Still going: treat it as the touch it is, and keep polling at the shorter + // cadence rather than sleeping the full idle timeout again. + _touched = _time.GetTimestamp(); + touchWorkspace = true; + rescheduled = true; + _ = _timer.Change(_backgroundPollInterval, Timeout.InfiniteTimeSpan); + } + else + { + _ended = true; + StopLocked(); + } + } + else + { + // The turn alone has held the session past its idle timeout. Stamped now, and + // again at about half the idle timeout for as long as it keeps running, so a turn + // that runs on well past this point is never mistaken for dead by another server + // sharing the workspace root. The completion continuation below — which restarts + // the idle clock the moment the turn frees the slot — is attached once per turn, + // not once per poll, so a long turn does not stack a callback on every tick. + touchWorkspace = true; + + if (!ReferenceEquals(_turnHeartbeat, turn)) + { + _turnHeartbeat = turn; + attachHeartbeatTo = turn; + } + + rescheduled = true; + _ = _timer.Change(_idleTimeout / 2, Timeout.InfiniteTimeSpan); + } + } + } + } + + if (stopped) + { + return; + } + + // The workspace stamp is disk I/O; it runs after the gate is released so it never holds up a + // concurrent TryTouch, IsEnded, or TryEnd call on the same session. + if (touchWorkspace) + { + Session.Lifetime.TouchWorkspace(); + } + + // The idle clock restarts when the turn frees the slot. Closing now would wait on the turn anyway. + _ = (attachHeartbeatTo?.ContinueWith( + static (_, state) => ((HeldSession)state!).TryTouch(), + this, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default)); + + if (rescheduled) + { + return; + } + + if (turn is null) + { + _expired(this); + } + } + + /// + /// How long the timer should sleep from a fresh touch: the poll cadence for a document that may have + /// background children, or the full idle timeout for one that cannot. + /// + private TimeSpan NextDueLocked() + { + return Session.Lifetime.MayHaveBackgroundChildren ? _backgroundPollInterval : _idleTimeout; + } + + private void StopLocked() + { + if (!_stopped) + { + _stopped = true; + _timer.Dispose(); + } + } + + private ITimer CreateTimer() + { + // The timer would otherwise capture the context of the request that opened the session. The expiry + // would then run an idle timeout later inside that request's trace and logging scopes. + bool restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + _ = ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + return _time.CreateTimer( + static state => ((HeldSession)state!).OnIdle(), this, NextDueLocked(), Timeout.InfiniteTimeSpan); + } + finally + { + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } +} diff --git a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs index 9c04e2fd..5679b84c 100644 --- a/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs +++ b/src/AgentCore.Application/Sessions/Memory/InMemoryConversationSessions.cs @@ -1,162 +1,278 @@ -using AgentCore.Domain.Audit; using System.Collections.Concurrent; +using AgentCore.Application.Conversation; +using AgentCore.Application.Diagnostics; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; +using Microsoft.Extensions.Logging; namespace AgentCore.Application.Sessions.Memory { /// /// The default . It holds every session in this process. /// - /// - /// - /// This does not survive a restart and does not span instances. A process that stops loses every - /// call it was holding, and a second instance behind a load balancer never sees the sessions of the - /// first. A deployment that needs either registers another before - /// AddAgentCore, and this steps aside. - /// - /// - /// A session is dropped when it has been untouched for the idle timeout. The voice path does not - /// wait for that — the socket closing is a real end, and it closes the conversation itself. The timeout is - /// for the text path, where a caller who simply stops replying never reaches a terminal stage and - /// would otherwise be held for the life of the process. - /// - /// - public sealed class InMemoryConversationSessions : IConversationSessions + public sealed class InMemoryConversationSessions : IConversationSessions, IDisposable { /// The idle timeout a host gets when it names none. - public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(30); + public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(10); + + // The longest due time ITimer.Change accepts. + private static readonly TimeSpan MaxIdleTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + private readonly ConcurrentDictionary _slots = new(StringComparer.Ordinal); + + private readonly IReadOnlyDictionary _factories; - private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); - private readonly IConversationSessionFactory _factory; private readonly TimeSpan _idleTimeout; + private readonly TimeProvider _time; + private volatile bool _disposed; + /// Creates the store. - /// Builds the session of a conversation that is not held yet. - /// How long an untouched session is kept. It slides on every read. - /// The clock the idle timeout is measured on. + /// Builds the session of a conversation that is not held yet, keyed by entry name. + /// + /// How long an untouched session is kept. It slides on every read, and restarts when a running turn ends. + /// At most about 49 days. + /// + /// The clock the idle timers run on. + /// + /// is not positive, or is longer than a timer can wait. + /// public InMemoryConversationSessions( - IConversationSessionFactory factory, TimeSpan idleTimeout, TimeProvider timeProvider) + IReadOnlyDictionary factories, TimeSpan idleTimeout, TimeProvider timeProvider) { - ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(factories); ArgumentNullException.ThrowIfNull(timeProvider); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(idleTimeout, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfGreaterThan(idleTimeout, MaxIdleTimeout); - _factory = factory; + _factories = factories; _idleTimeout = idleTimeout; _time = timeProvider; } - /// Gets how many sessions this holds. - public int Count => _sessions.Count; + /// Gets how many live sessions this holds. A session still being built, or already closing, is not counted. + public int Count => _slots.Values.Count(static slot => slot.TryGetHeld(out HeldSession? held) && !held.IsEnded); /// - public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default) + /// This store was disposed. + /// is empty, or names no declared entry. + public async ValueTask GetOrOpenAsync( + string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrEmpty(entry); + IConversationSessionFactory factory = FactoryFor(entry); + + // Minted here rather than by the factory, so a new conversation reserves its id like any other. + string id = string.IsNullOrWhiteSpace(conversationId) ? Guid.NewGuid().ToString("N") : conversationId; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_slots.TryGetValue(id, out SessionSlot? slot)) + { + SessionSlot reserved = new(entry); + if (_slots.TryAdd(id, reserved)) + { + return Build(id, reserved, factory, state); + } + + continue; + } + + // A closing session's folder is about to be deleted, whichever entry held it. + if (slot.TryGetHeld(out HeldSession? closing) && closing.IsEnded) + { + await slot.Released.WaitAsync(cancellationToken).ConfigureAwait(false); + continue; + } - ConversationSession session = _factory.Create(conversationId); - _sessions[session.ConversationId] = new Entry(session, _time.GetUtcNow()); - return ValueTask.FromResult(session); + if (!string.Equals(slot.Entry, entry, StringComparison.Ordinal)) + { + throw new ConversationInUseException(id, slot.Entry, entry); + } + + HeldSession held = await slot.Opened.WaitAsync(cancellationToken).ConfigureAwait(false); + if (held.TryTouch()) + { + return held.Session; + } + + await slot.Released.WaitAsync(cancellationToken).ConfigureAwait(false); + } } /// - public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default) + /// is empty. + public ValueTask TryGetAsync( + string entry, string conversationId, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(entry); ArgumentNullException.ThrowIfNull(conversationId); cancellationToken.ThrowIfCancellationRequested(); - if (!_sessions.TryGetValue(conversationId, out Entry? entry)) - { - return ValueTask.FromResult(null); - } - - _ = _sessions.TryUpdate(conversationId, entry with { Touched = _time.GetUtcNow() }, entry); - return ValueTask.FromResult(entry.Session); + return ValueTask.FromResult( + _slots.TryGetValue(conversationId, out SessionSlot? slot) + && string.Equals(slot.Entry, entry, StringComparison.Ordinal) + && slot.TryGetHeld(out HeldSession? held) + && held.TryTouch() + ? held.Session + : null); } /// - public async ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default) + /// is empty. + public async ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(entry); ArgumentNullException.ThrowIfNull(conversationId); - // Taken out first so a second caller cannot be handed a session that is already draining, - // then flushed while this method still holds the only reference to it. - if (_sessions.TryRemove(conversationId, out Entry? entry)) + if (!_slots.TryGetValue(conversationId, out SessionSlot? slot) + || !string.Equals(slot.Entry, entry, StringComparison.Ordinal) + || !slot.TryGetHeld(out HeldSession? held)) { - // A store that throws on flush must not leave the session's shells running with - // nothing left to hold a reference to them. - try - { - await entry.Session.FlushTranscriptAsync().ConfigureAwait(false); - } - finally - { - await entry.Session.DisposeAsync().ConfigureAwait(false); - } + return; + } + + if (held.TryEnd()) + { + await TearDownAsync(conversationId, slot, held).ConfigureAwait(false); + } + else + { + await slot.Released.WaitAsync(cancellationToken).ConfigureAwait(false); } } - /// Closes every session that has been untouched for the idle timeout. - /// Cancels the sweep. - /// A task that completes once every expired session is closed. - /// - /// One or more sessions could not be ended. The sweep still visited every other session first. - /// - /// - /// This goes through and not the dictionary, so an expiring session - /// hands over its words on the way out exactly as one the host closed by hand. - /// - public async ValueTask SweepAsync(CancellationToken cancellationToken = default) + /// + /// Sweeps for a folder a crashed process left behind. See + /// for what counts as one, and why. Meant to run once, at + /// boot, before this owner has opened any session of its own; a folder this owner already holds by the + /// time this runs is skipped regardless of its stamp, so a caller that runs it later never sweeps its + /// own live session. + /// + /// The workspace root a host bound. + /// Where a failed list or delete is logged, at Warning. May be . + /// is empty. + internal void SweepWorkspaceRoot(string root, ILogger? logger) { - DateTimeOffset cutoff = _time.GetUtcNow() - _idleTimeout; - List? faults = null; + new WorkspaceRootSweeper(_idleTimeout, _time).Sweep(root, id => _slots.ContainsKey(id), logger); + } - foreach ((string? conversationId, Entry? entry) in _sessions.ToArray()) - { - cancellationToken.ThrowIfCancellationRequested(); + /// Stops every idle timer. A build or a close already running finishes, and wakes its waiters. + public void Dispose() + { + _disposed = true; - if (entry.Touched > cutoff) + foreach (SessionSlot slot in _slots.Values) + { + if (slot.TryGetHeld(out HeldSession? held)) { - continue; + held.Dispose(); } + } + } - // One session that will not end must not leave every session after it in the - // dictionary for the life of the process, which is the leak this sweep exists to stop. + /// + /// The one close routine every way out runs: flush, dispose (background children, then shells), delete + /// the workspace folder. A store that throws on flush must not leave the shells running with nothing + /// left to hold a reference to them, and a dispose that throws must not leave the folder undeleted. + /// + private static async ValueTask CloseSessionAsync(ConversationSession session) + { + try + { try { - await ExpireAsync(conversationId, entry.Session, cancellationToken).ConfigureAwait(false); + await session.FlushTranscriptAsync().ConfigureAwait(false); } - catch (Exception fault) when (fault is not OperationCanceledException) + finally { - (faults ??= []).Add(fault); + await session.DisposeAsync().ConfigureAwait(false); } } + finally + { + session.Lifetime.DeleteWorkspace(); + } + } + + /// Builds the session of an id this caller reserved, and hands it to every caller waiting on the slot. + private ConversationSession Build( + string conversationId, SessionSlot slot, IConversationSessionFactory factory, ConversationSessionState? state) + { + HeldSession held; + try + { + ConversationSession session = factory.Create(conversationId, state); + held = new(session, _idleTimeout, _time, expired => Expire(conversationId, slot, expired)); + } + catch (Exception fault) + { + _ = _slots.TryRemove(KeyValuePair.Create(conversationId, slot)); + slot.Fail(fault); + throw; + } + + slot.Open(held); - if (faults is not null) + // A Dispose that ran before the slot was opened did not see this timer. + if (_disposed) { - throw new AggregateException(faults); + held.Dispose(); } + + return held.Session; + } + + private IConversationSessionFactory FactoryFor(string entry) + { + return _factories.TryGetValue(entry, out IConversationSessionFactory? factory) + ? factory + : throw new ArgumentException( + $"The entry '{entry}' is not declared. Valid entries: {string.Join(", ", _factories.Keys)}.", + nameof(entry)); + } + + /// Starts the unload of one expired session. Its idle timer ended it, so no other close runs. + private void Expire(string conversationId, SessionSlot slot, HeldSession held) + { + _ = ExpireAsync(conversationId, slot, held); } - /// Ends one expired conversation, chain first and session second. - /// - /// §11 item 6 makes conversation.ended the last event of every conversation, and expiry is a way a conversation - /// ends. Nothing else writes it here: the relay closes its own chain from the socket and the - /// turn loop closes its own from a terminal stage, so a caller who simply stops replying is the - /// one ending that would otherwise leave a chain with no end. - /// is idempotent, so a session that already closed its chain keeps the reason it wrote. - /// The reason is because the closed set of §6 holds no - /// reason for an abandoned conversation; see the amendment owed on that enum. - /// - private async ValueTask ExpireAsync( - string conversationId, ConversationSession session, CancellationToken cancellationToken) + /// Unloads one expired conversation. It writes no end event; the conversation stays open. + private async Task ExpireAsync(string conversationId, SessionSlot slot, HeldSession held) { - _ = session.EndConversation(ConversationEndReason.Faulted); - await CloseAsync(conversationId, cancellationToken).ConfigureAwait(false); + // Nothing awaits an expiry, so nothing may escape it. +#pragma warning disable CA1031 + try + { + await TearDownAsync(conversationId, slot, held).ConfigureAwait(false); + } + catch (Exception exception) + { + SessionOwnerLog.SessionExpiryFailed(held.Session.Logger, conversationId, exception); + } +#pragma warning restore CA1031 } - private sealed record Entry(ConversationSession Session, DateTimeOffset Touched); + /// + /// Tears down a session whose close this caller began, then gives up its slot. The slot stays taken until + /// the teardown is done, so an open of the id waits instead of racing the folder delete. + /// + private async Task TearDownAsync(string conversationId, SessionSlot slot, HeldSession held) + { + try + { + await CloseSessionAsync(held.Session).ConfigureAwait(false); + } + finally + { + _ = _slots.TryRemove(KeyValuePair.Create(conversationId, slot)); + slot.Release(); + } + } } } diff --git a/src/AgentCore.Application/Sessions/Memory/SessionSlot.cs b/src/AgentCore.Application/Sessions/Memory/SessionSlot.cs new file mode 100644 index 00000000..fa4daff1 --- /dev/null +++ b/src/AgentCore.Application/Sessions/Memory/SessionSlot.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; + +namespace AgentCore.Application.Sessions.Memory +{ + /// + /// The place one conversation id takes in . An open reserves it + /// before the session is built, and it is given up only once the build failed or the session's teardown is + /// done, so no second session of the id can start while the first is still being built or torn down. + /// + /// The entry that reserved the id. + internal sealed class SessionSlot(string entry) + { + private readonly TaskCompletionSource _opened = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private volatile HeldSession? _held; + + /// Gets the entry that reserved the id. + internal string Entry { get; } = entry; + + /// Gets a task that completes with the held session once it is built, or faults with what the build threw. + internal Task Opened => _opened.Task; + + /// + /// Gets a task that completes once this slot is out of the dictionary: its build failed, or its session's + /// teardown is done. + /// + internal Task Released => _released.Task; + + /// Reads the held session, once it is built. + internal bool TryGetHeld([NotNullWhen(true)] out HeldSession? held) + { + held = _held; + return held is not null; + } + + /// Hands the built session to every caller waiting on . + internal void Open(HeldSession held) + { + _held = held; + _opened.SetResult(held); + } + + /// Hands the build's fault to every caller waiting on . Call it once the slot is out of the dictionary. + internal void Fail(Exception fault) + { + _opened.SetException(fault); + + // The builder rethrows the fault itself, so a slot nobody else waited on must not report it again as unobserved. + _ = _opened.Task.Exception; + _released.SetResult(); + } + + /// Wakes every caller waiting for the teardown. Call it once the slot is out of the dictionary. + internal void Release() + { + _released.SetResult(); + } + } +} diff --git a/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs new file mode 100644 index 00000000..7f07eb63 --- /dev/null +++ b/src/AgentCore.Application/Sessions/Memory/WorkspaceRootSweeper.cs @@ -0,0 +1,151 @@ +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Sessions.Memory +{ + /// + /// The boot sweep runs once, at startup, over its workspace root. + /// + internal sealed class WorkspaceRootSweeper + { + private readonly TimeSpan _idleTimeout; + + private readonly TimeProvider _time; + + /// Creates the sweeper over one owner's idle timeout and clock. + /// The owner's idle timeout: how old a folder or a lone marker must be to sweep. + /// The clock the owner's idle timers run on. + internal WorkspaceRootSweeper(TimeSpan idleTimeout, TimeProvider time) + { + _idleTimeout = idleTimeout; + _time = time; + } + + /// + /// Deletes every folder directly under that has a sibling marker + /// () and whose last-write time is at least the idle + /// timeout old: a folder a crashed process left behind. The marker goes with it. A folder without a + /// marker is never a folder made, so it is left alone + /// untouched, whatever its stamp — a workspace root that happens to point at a shared or pre-existing + /// folder must lose nothing this library did not put there. A marker whose folder is already gone is a + /// leftover of its own — from a folder deleted out of band, say — and is deleted once its own stamp is + /// old enough, by the same cutoff. Meant to run once, at boot, before the owner has opened any session + /// of its own; a folder the owner already holds by the time this runs is skipped regardless of its + /// stamp, so a caller that runs it later never sweeps a live session. A folder that is a symbolic link + /// is skipped untouched, so this never follows a link out of the root. Never throws: a folder, a + /// marker, or the root itself, that cannot be listed, inspected, or deleted is logged and left for the + /// next boot. + /// + /// The workspace root a host bound. + /// Answers whether a conversation id is already held live by the calling owner. + /// Where a failed list or delete is logged, at Warning. May be . + /// is empty. + internal void Sweep(string root, Func isLive, ILogger? logger) + { + ArgumentException.ThrowIfNullOrEmpty(root); + + string[] folders; + string[] markers; + try + { + // Eager, not EnumerateDirectories/EnumerateFiles: a lazy sequence would run its own OS-level + // listing calls outside this try, on whatever caller code enumerates it, so a fault partway + // through a large or unreachable folder would escape uncaught instead of being logged here. + folders = Directory.GetDirectories(root); + markers = Directory.GetFiles(root, "*" + ConversationWorkspace.MarkerFileName); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException) + { + if (logger is not null) + { + SessionOwnerLog.WorkspaceSweepListFailed(logger, root, exception); + } + + return; + } + + // No margin for clock skew: a root shared by several servers is the host's to coordinate. + DateTime cutoff = _time.GetUtcNow().UtcDateTime - _idleTimeout; + + HashSet liveFolderNames = new(folders.Select(folder => Path.GetFileName(folder)!), StringComparer.Ordinal); + + foreach (string folder in folders) + { + if (isLive(Path.GetFileName(folder)!)) + { + continue; + } + + try + { + DirectoryInfo info = new(folder); + if (info.LinkTarget is not null) + { + continue; + } + + string marker = ConversationWorkspace.MarkerPathFor(folder); + if (!File.Exists(marker)) + { + continue; + } + + if (info.LastWriteTimeUtc <= cutoff) + { + Directory.Delete(folder, recursive: true); + TryDeleteMarker(marker, logger); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException) + { + if (logger is not null) + { + SessionOwnerLog.WorkspaceSweepDeleteFailed(logger, folder, exception); + } + } + } + + foreach (string marker in markers) + { + string id = Path.GetFileName(marker)[..^ConversationWorkspace.MarkerFileName.Length]; + if (liveFolderNames.Contains(id)) + { + // Has a folder: handled above, alongside it, whichever way that went. + continue; + } + + try + { + if (File.GetLastWriteTimeUtc(marker) <= cutoff) + { + TryDeleteMarker(marker, logger); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FileNotFoundException) + { + if (logger is not null) + { + SessionOwnerLog.WorkspaceSweepDeleteFailed(logger, marker, exception); + } + } + } + } + + /// Deletes one workspace marker file. A failure is logged, never thrown. + private static void TryDeleteMarker(string marker, ILogger? logger) + { + try + { + File.Delete(marker); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (logger is not null) + { + SessionOwnerLog.WorkspaceMarkerDeleteFailed(logger, marker, exception); + } + } + } + } +} diff --git a/src/AgentCore.Application/State/StateDocument.cs b/src/AgentCore.Application/State/StateDocument.cs index 309c011f..6d48f2d5 100644 --- a/src/AgentCore.Application/State/StateDocument.cs +++ b/src/AgentCore.Application/State/StateDocument.cs @@ -136,12 +136,6 @@ private static bool IsMember(IReadOnlyList members, JsonNode? value) /// Reads the declared slots a writer has actually filled, for the durable blob. /// A copy. An unfilled slot is absent, which is what keeps unfilled and filled-default apart. - /// - /// Not : that one fills every declared slot with its default and adds the - /// three reserved slots, which is right for a guard and wrong for a blob. Restoring a default as - /// though a writer had chosen it would lose the difference - /// exists to keep. - /// public IReadOnlyDictionary WrittenSlots() { Dictionary written = new(_written.Count, StringComparer.Ordinal); @@ -154,6 +148,12 @@ private static bool IsMember(IReadOnlyList members, JsonNode? value) return written; } + /// Empties every declared slot a writer filled, so each reads as its declared default again. + internal void ClearWrittenSlots() + { + _written.Clear(); + } + private JsonValue? ReadReserved(string slot) { if (string.Equals(slot, ReservedStateSlots.Stage, StringComparison.Ordinal)) diff --git a/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs b/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs index 832a521d..d4e1bf08 100644 --- a/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs +++ b/src/AgentCore.Application/Transcript/AgentCoreChatHistoryProvider.cs @@ -1,7 +1,5 @@ -using System.Runtime.CompilerServices; using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; -using AgentCore.Application.Diagnostics; using AgentCore.Application.Ports; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -11,21 +9,36 @@ namespace AgentCore.Application.Transcript { /// - /// Reports one store 1 write that was dropped, so the conversation can raise a diagnostic for it. + /// Store the words of a conversation, held for the session by the provider and written through to a backing store. /// - internal delegate void TranscriptWriteDropped(int turnIndex, Exception exception); - - /// - /// Store 1: the words of a conversation, held for the session by the provider and written through to a backing store. - /// - /// Creates the provider over a backing store. - internal sealed class AgentCoreChatHistoryProvider(IConversationStore? store = null, ILogger? logger = null) : ChatHistoryProvider + internal sealed class AgentCoreChatHistoryProvider : ChatHistoryProvider { - private readonly ConditionalWeakTable _gates = []; + /// + /// The state bag key that binds a session to its conversation. A session without it reads nothing, stages + /// nothing and commits nothing, whatever else its bag carries (design probe G4). + /// + internal const string ConversationKey = "agentcore.history"; + + private static readonly IReadOnlyList Keys = [ConversationKey]; - private readonly IConversationStore _store = store ?? new InMemoryConversationStore(); + private readonly ConversationGates _gates; - private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly ConversationWrites _writes; + + /// Creates the provider over a backing store. + /// Store 1, or for one kept in this process. + /// Where refused writes and cuts are logged. + public AgentCoreChatHistoryProvider(IConversationStore? store = null, ILogger? logger = null) + : base(storeInputRequestMessageFilter: ExternalOnly) + { + ILogger log = logger ?? NullLogger.Instance; + IConversationStore words = store ?? new InMemoryConversationStore(); + _gates = new ConversationGates(words, log); + _writes = new ConversationWrites(_gates, words, log); + } + + /// + public override IReadOnlyList StateKeys => Keys; /// /// Opens a conversation on one session: names it, reads back what it already said, and says where a @@ -37,7 +50,7 @@ internal sealed class AgentCoreChatHistoryProvider(IConversationStore? store = n /// What store 1 already holds for this conversation, which is empty for a conversation that is new. A second /// session of one conversation is handed the first session's words here, and nowhere else. /// - /// Where a dropped store 1 write is reported, if anywhere. + /// Where a dropped store 1 write is counted, if anywhere. /// /// How far the conversation had got, from store 0's own counters — or the zero marks of a conversation that has /// never spoken. The words cannot say this on their own, because an edit deletes the rows that @@ -48,50 +61,63 @@ public int BeginConversation( AgentSession session, string conversationId, IReadOnlyList spoken, - TranscriptWriteDropped? report = null, + ITranscriptLossCounter? losses = null, TranscriptMarks marks = default) { ArgumentNullException.ThrowIfNull(session); ArgumentException.ThrowIfNullOrEmpty(conversationId); ArgumentNullException.ThrowIfNull(spoken); + session.StateBag.SetValue(ConversationKey, conversationId); + return UnderLock( session, (transcript, gate) => { transcript.ConversationId = conversationId; - gate.Dropped = report; + gate.Losses = losses; return transcript.Resume(spoken, marks); }); } - /// Reads the ordinal the session expects the conversation's next row to take. + /// Reads where the session's words stand: the writes queued so far, the revision, and the next ordinal, together. /// The session this conversation runs on. /// - /// The next free ordinal as the session counts it. Equal to store 0's own counter unless a row was - /// written by someone else, or one of this session's own writes was dropped. + /// The position. Once completes, store 0's counter equals + /// unless a row was written by someone else, or one of this + /// session's own writes was dropped. /// - public int NextOrdinal(AgentSession session) + public TranscriptPosition Position(AgentSession session) { ArgumentNullException.ThrowIfNull(session); - return UnderLock(session, static transcript => transcript.NextOrdinal); + return UnderLock( + session, + static (transcript, gate) => new TranscriptPosition(gate.Writes, transcript.Revision, transcript.NextOrdinal)); } - /// Replaces the conversation's live history with what store 1 holds. + /// + /// Replaces the conversation's live history with what store 1 holds, unless the session's words moved since + /// the read began. Words that moved hold a write store 1 may not have yet, and replacing them would lose it. + /// /// The session this conversation runs on. - /// The rows store 1 read for the session, as takes them. - /// The next free ordinal of the conversation, from store 0's own counter. - public void Resync(AgentSession session, IReadOnlyList rows, int nextOrdinal) + /// What store 1 held, and the revision the read began at. + /// when the words moved and nothing was replaced. + public bool TryResync(AgentSession session, TranscriptCatchUp catchUp) { ArgumentNullException.ThrowIfNull(session); - ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(catchUp); - _ = UnderLock( + return UnderLock( session, transcript => { - transcript.Resync(rows, nextOrdinal); + if (transcript.Revision != catchUp.Revision) + { + return false; + } + + transcript.Resync(catchUp.Rows, catchUp.NextOrdinal); return true; }); } @@ -130,25 +156,11 @@ public bool Compact(AgentSession session, ChatMessage summary, int coversUpTo, i ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(summary); - return UnderLock( - session, - (transcript, gate) => - { - if (transcript.Compact(summary, coversUpTo, revision) is not { } row) - { - return false; - } - - ConversationMessageDraft draft = new(row.TurnIndex, row.Content, row.MessageId) { CoversUpTo = row.CoversUpTo }; - gate.Enqueue(() => new ValueTask(_store.AppendAsync( - transcript.ConversationId, [draft], state: null, CancellationToken.None).AsTask())); - - return true; - }); + return _writes.Compact(session, summary, coversUpTo, revision); } /// - /// Whether can cut an edit hanging off one message from what the session + /// Whether can cut an edit hanging off one message from what the session /// holds. It cannot when the cut would reach a row the summary stands in for; every row must be read back first. /// /// The session this conversation runs on. @@ -161,7 +173,7 @@ public bool CanTruncateFrom(AgentSession session, string? parentMessageId) } /// - /// Stamps the turn the next run belongs to. + /// Stamps the turn the next run belongs to, and drops anything an earlier turn staged and never committed. /// public void BeginTurn(AgentSession session, int turnIndex) { @@ -169,9 +181,10 @@ public void BeginTurn(AgentSession session, int turnIndex) _ = UnderLock( session, - transcript => + (transcript, gate) => { transcript.BeginTurn(turnIndex); + gate.Staged.Clear(); return true; }); } @@ -186,47 +199,32 @@ public IReadOnlyList Read(AgentSession session) return UnderLock(session, static transcript => transcript.Read()); } - /// Adds one finished turn's messages to the conversation, and the state that follows them. + /// Reads what the framework staged for the running turn and no commit has taken yet, oldest first. /// The session this conversation runs on. - /// The messages the turn produced. - /// - /// The state to store beside the words, or to store none. A value, read - /// by the caller before it conversations: everything the turn writes to the state document — the clock - /// fields, the counters, the stage and whether the machine finished — is already final when the - /// caller enters its commit lock, and the late barge-in path that runs after this conversation amends - /// the record of the turn without touching any of it. So there is nothing later to wait for. - /// - /// - /// What the caller calls the first of these messages, or to name it in the - /// append. Only the first: the rest are this conversation's own words, which no caller had a name for. - /// - /// - /// The name the last of these messages was written under, so the caller can be told what to hang - /// its next edit off. It is only when nothing was written. - /// - public string? AppendTurn( - AgentSession session, - IReadOnlyList messages, - ConversationSessionState? state = null, - string? firstMessageId = null) + public IReadOnlyList Staged(AgentSession session) { ArgumentNullException.ThrowIfNull(session); - ArgumentNullException.ThrowIfNull(messages); - return messages.Count == 0 - ? null - : UnderLock( - session, - (transcript, gate) => - { - IReadOnlyList rows = transcript.Append(messages, firstMessageId); + return UnderLock(session, static (_, gate) => (IReadOnlyList)[.. gate.Staged]); + } - ConversationMessageDraft[] drafts = [.. rows.Select(row => new ConversationMessageDraft(row.TurnIndex, row.Content, row.MessageId))]; + /// + /// Seals one turn: the one durable append of that turn, holding its words and the state that follows + /// them. Whatever the framework staged for the turn is taken here, and nothing stays staged. + /// + /// The session this conversation runs on. + /// The turn being sealed. + /// + /// What the user's message and the last message were written under, and whether the store kept them, or + /// when the session is bound to no conversation and nothing was written. The reply id + /// is when the user's message is all the turn wrote. + /// + public TurnWrite? CommitTurn(AgentSession session, TurnCommit commit) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(commit); - gate.Enqueue(() => new ValueTask(_store.AppendAsync( - transcript.ConversationId, drafts, state, CancellationToken.None).AsTask())); - return rows[^1].MessageId; - }); + return IsBound(session) ? _writes.Commit(session, commit) : null; } /// Withdraws everything the conversation said after one message, because a caller replaced it. @@ -236,6 +234,7 @@ public IReadOnlyList Read(AgentSession session) /// to withdraw the whole conversation, which is what an edit of its first message /// asks for. /// + /// Cancels the read that judges the session's lost writes before the cut. /// /// The turns the withdrawal took, or when nothing went. Nothing goes when /// the conversation holds no message of that name — a caller naming a message this host never stored — @@ -245,91 +244,41 @@ public IReadOnlyList Read(AgentSession session) /// The cut reaches a row the summary stands in for. Ask first, and read every /// row back when it says no. /// - public WithdrawnTurns? TruncateFrom(AgentSession session, string? parentMessageId) + public ValueTask TruncateFromAsync(AgentSession session, string? parentMessageId, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); - return UnderLock( - session, - (transcript, gate) => - { - int from; - if (parentMessageId is null) - { - from = 0; - } - else if (transcript.OrdinalOf(parentMessageId) is { } parent) - { - from = parent + 1; - } - else - { - return (WithdrawnTurns?)null; - } - - if (transcript.TruncateFrom(from) is not { } withdrawn) - { - return null; - } - - Log.ConversationTruncated(_logger, transcript.ConversationId, from, transcript.TurnIndex); - - gate.Enqueue(() => new ValueTask( - _store.TruncateAsync(transcript.ConversationId, from, CancellationToken.None).AsTask())); - - return withdrawn; - }); + return _writes.TruncateFromAsync(session, parentMessageId, cancellationToken); } - /// Adds the caller-facing turn of a graph row: what the caller said, and what it heard. + /// + /// Withdraws an edit that reaches under the summary: the parent is a row the summary stands in for, or the + /// edit takes the whole conversation. The store cuts the rows, and the session reads every row back. + /// Ask first, and come here when it says no. + /// /// The session this conversation runs on. - /// What the caller said. - /// What the caller heard, or when it heard nothing. - /// - /// The state to store beside the words, on the same terms as - /// . - /// - /// What the caller calls the message it sent, if it named one. - /// - public string? AppendCallerFacingTurn( - AgentSession session, - ChatMessage spoken, - ChatMessage? heard, - ConversationSessionState? state = null, - string? firstMessageId = null) + /// The message the edit hangs off, or for the whole conversation. + /// Cancels the cut. + /// The turns the cut withdrew, or when nothing went. + public ValueTask CutUnderSummaryAsync( + AgentSession session, string? parentMessageId, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(spoken); + ArgumentNullException.ThrowIfNull(session); - return AppendTurn(session, heard is null ? [spoken] : [spoken, heard], state, firstMessageId); + return _writes.CutUnderSummaryAsync(session, parentMessageId, cancellationToken); } - /// - /// Replaces the reply the caller was hearing with the words the caller actually heard. - /// - public bool TruncateLastReply(AgentSession session, string heard, TimeSpan played) + /// Rewrites the reply of a turn already committed to the text the user was shown. It never appends. + /// The session this conversation runs on. + /// The turn whose reply is rewritten. It must be the turn of the conversation's last reply. + /// The text the user saw or heard. + /// when the last reply belongs to another turn, or there is none. + public bool RewriteReply(AgentSession session, int turnIndex, string shown) { ArgumentNullException.ThrowIfNull(session); - ArgumentNullException.ThrowIfNull(heard); + ArgumentNullException.ThrowIfNull(shown); - return UnderLock( - session, - (transcript, gate) => - { - IReadOnlyList rows = transcript.TruncateLastReply(heard); - if (rows.Count == 0) - { - return false; - } - - Log.ReplyTruncated(_logger, rows[0].ConversationId, rows[0].TurnIndex, played.TotalMilliseconds); - - foreach (ConversationMessage row in rows) - { - gate.Enqueue(() => _store.RewriteAsync(row.ConversationId, row.MessageId, row.Content, CancellationToken.None)); - } - - return true; - }); + return _writes.Rewrite(session, turnIndex, shown); } /// @@ -339,11 +288,7 @@ public Task DrainAsync(AgentSession session) { ArgumentNullException.ThrowIfNull(session); - ConversationGate gate = GateFor(session); - lock (gate.Sync) - { - return gate.Writes; - } + return _gates.Writes(session); } /// @@ -352,35 +297,54 @@ protected override ValueTask> ProvideChatHistoryAsync( { ArgumentNullException.ThrowIfNull(context); - return new(context.Session is { } session ? Read(session) : []); + return new(context.Session is { } session && IsBound(session) ? Read(session) : []); } - /// + /// + /// Stages the run's response for the turn. It writes nothing durable: the hook runs before the stage + /// machine and the fallback layer, so the words and their state are written together by + /// . Staging appends, and never returns + /// staged messages (design section 7, item 1). + /// protected override ValueTask StoreChatHistoryAsync( InvokedContext context, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(context); + + if (context.Session is { } session && IsBound(session) && context.ResponseMessages is { } response) + { + _ = UnderLock( + session, + (_, gate) => + { + gate.Staged.AddRange(response); + return true; + }); + } + return default; } - /// Runs one piece of work against the conversation's transcript, alone. - private TResult UnderLock(AgentSession session, Func work) + private static bool IsBound(AgentSession session) { - ConversationGate gate = GateFor(session); + return session.StateBag.TryGetValue(ConversationKey, out string? _); + } - lock (gate.Sync) - { - return work(gate.Transcript, gate); - } + // The user row comes from the turn, so the hook keeps only what the caller sent: context providers' + // system lines (clock, reminders, skills) arrive in the request too (design probe G1). + private static IEnumerable ExternalOnly(IEnumerable messages) + { + return messages.Where(message => message.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External); } - private TResult UnderLock(AgentSession session, Func work) + private TResult UnderLock(AgentSession session, Func work) { - return UnderLock(session, (transcript, _) => work(transcript)); + return _gates.Under(session, work); } - private ConversationGate GateFor(AgentSession session) + private TResult UnderLock(AgentSession session, Func work) { - return _gates.GetValue(session, _ => new ConversationGate(_logger)); + return UnderLock(session, (transcript, _) => work(transcript)); } } } diff --git a/src/AgentCore.Application/Transcript/ConversationGate.cs b/src/AgentCore.Application/Transcript/ConversationGate.cs index 74b6bd45..edc616e9 100644 --- a/src/AgentCore.Application/Transcript/ConversationGate.cs +++ b/src/AgentCore.Application/Transcript/ConversationGate.cs @@ -1,4 +1,7 @@ +using AgentCore.Application.Conversation; using AgentCore.Application.Diagnostics; +using AgentCore.Application.Ports; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace AgentCore.Application.Transcript @@ -8,9 +11,12 @@ namespace AgentCore.Application.Transcript /// store writes, which never faults and lets the conversation outlive a store that refuses. /// /// Creates the gate of one conversation. + /// Store 1, where every queued write lands. /// Where a refused write is logged. - internal sealed class ConversationGate(ILogger logger) + internal sealed class ConversationGate(IConversationStore store, ILogger logger) { + private readonly IConversationStore _store = store; + private readonly ILogger _logger = logger; /// Gets the lock every read and every change of this conversation's transcript takes. @@ -22,24 +28,94 @@ internal sealed class ConversationGate(ILogger logger) /// Gets the tail of this conversation's store writes. It never faults. public Task Writes { get; private set; } = Task.CompletedTask; - /// Gets or sets where a dropped write of this conversation is reported, if anywhere. - public TranscriptWriteDropped? Dropped { get; set; } + /// Gets or sets where a dropped write of this conversation is counted, if anywhere. + public ITranscriptLossCounter? Losses { get; set; } + + /// + /// Gets what the framework handed the provider for the running turn and nothing has committed yet. + /// It grows once per inner run, because a loop: entry and an approval re-entry run the agent + /// several times in one turn. + /// + public List Staged { get; } = []; + + /// Takes everything staged for the running turn, and leaves nothing staged. + public List TakeStaged() + { + List staged = [.. Staged]; + Staged.Clear(); + return staged; + } + + /// Queues the append of rows the transcript just took, with the state that rides their batch. + /// + /// Completes once the append landed or was dropped: when the store refused it because the + /// conversation already saved the turn it names. + /// + public Task QueueAppend(IReadOnlyList rows, ConversationSessionState? state) + { + ConversationMessageDraft[] drafts = [.. rows.Select(row => new ConversationMessageDraft(row.TurnIndex, row.Content, row.MessageId) { CoversUpTo = row.CoversUpTo })]; + string conversationId = Transcript.ConversationId; + return Enqueue( + () => new ValueTask(_store.AppendAsync(conversationId, drafts, state, CancellationToken.None).AsTask()), + [.. drafts.Select(draft => draft.MessageId)]); + } + + /// Queues the rewrite of rows the transcript just changed in place. + public void QueueRewrite(IReadOnlyList rows) + { + foreach (ConversationMessage row in rows) + { + _ = Enqueue(() => _store.RewriteAsync(row.ConversationId, row.MessageId, row.Content, CancellationToken.None)); + } + } + + /// Queues the delete of rows the transcript just dropped. + public void QueueRemove(IReadOnlyList rows) + { + foreach (ConversationMessage row in rows) + { + _ = Enqueue(() => + { + Losses?.Withdrew([row.MessageId]); + return _store.DeleteMessageAsync(row.ConversationId, row.MessageId, CancellationToken.None); + }); + } + } + + /// Queues the delete of every row from one ordinal on. + /// The first ordinal to delete. + /// The ids of the rows the transcript just dropped for it. + public void QueueTruncate(int fromOrdinal, IReadOnlyList withdrawn) + { + string conversationId = Transcript.ConversationId; + _ = Enqueue(() => + { + Losses?.Withdrew(withdrawn); + return new ValueTask(_store.TruncateAsync(conversationId, fromOrdinal, CancellationToken.None).AsTask()); + }); + } /// Queues one store write behind everything this conversation has already queued. Runs under . /// The write. - public void Enqueue(Func write) + /// The ids of the rows the write appends, or none for a write that changes rows in place. + /// Completes once the write landed or was dropped: when the store refused a turn it already saved. + public Task Enqueue(Func write, IReadOnlyList? appended = null) { - Writes = WriteAfterAsync(Writes, write, Transcript.ConversationId, Transcript.TurnIndex); + Task written = WriteAfterAsync(Writes, write, Transcript.ConversationId, Transcript.TurnIndex, appended ?? []); + Writes = written; + return written; } /// Writes to the store, and lets the conversation outlive a store that refuses. - private async Task WriteAfterAsync(Task previous, Func write, string conversationId, int turnIndex) + private async Task WriteAfterAsync( + Task previous, Func write, string conversationId, int turnIndex, IReadOnlyList appended) { await previous.ConfigureAwait(false); try { await write().ConfigureAwait(false); + return false; } #pragma warning disable CA1031 // A store 1 write failure never ends a conversation, and never breaks the chain behind it. catch (Exception exception) @@ -47,10 +123,8 @@ private async Task WriteAfterAsync(Task previous, Func write, string { Log.TranscriptWriteFailed(_logger, conversationId, turnIndex, exception); - // The words are lost and the conversation is not. The report is what turns that into a fact the - // host can count; the contract of TranscriptWriteDropped is that it cannot throw, which - // is what keeps this chain from ever faulting. - Dropped?.Invoke(turnIndex, exception); + Losses?.Dropped(turnIndex, appended, exception); + return exception is ConversationTurnConflictException; } } } diff --git a/src/AgentCore.Application/Transcript/ConversationGates.cs b/src/AgentCore.Application/Transcript/ConversationGates.cs new file mode 100644 index 00000000..a9a499dc --- /dev/null +++ b/src/AgentCore.Application/Transcript/ConversationGates.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Ports; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Transcript +{ + /// + /// Finds the gate of each session and runs work against it alone. The provider is one object shared by + /// every conversation, so this is what keeps two sessions on two transcripts. + /// + /// Creates the registry over one backing store. + /// Store 1, which every gate writes through to. + /// Where a refused write is logged. + internal sealed class ConversationGates(IConversationStore store, ILogger logger) + { + private readonly ConditionalWeakTable _gates = []; + + private readonly IConversationStore _store = store; + + private readonly ILogger _logger = logger; + + /// Runs one piece of work against the session's transcript, under the session's lock. + public TResult Under(AgentSession session, Func work) + { + ConversationGate gate = GateFor(session); + + lock (gate.Sync) + { + return work(gate.Transcript, gate); + } + } + + /// Reads the tail of the session's store writes. It never faults. + public Task Writes(AgentSession session) + { + ConversationGate gate = GateFor(session); + lock (gate.Sync) + { + return gate.Writes; + } + } + + private ConversationGate GateFor(AgentSession session) + { + return _gates.GetValue(session, _ => new ConversationGate(_store, _logger)); + } + } +} diff --git a/src/AgentCore.Application/Transcript/ConversationTranscript.cs b/src/AgentCore.Application/Transcript/ConversationTranscript.cs index 318aa5be..5d0f2743 100644 --- a/src/AgentCore.Application/Transcript/ConversationTranscript.cs +++ b/src/AgentCore.Application/Transcript/ConversationTranscript.cs @@ -73,9 +73,6 @@ public void Resync(IReadOnlyList rows, int nextOrdinal) NextOrdinal = nextOrdinal; Revision++; - // Without this a caller who barges in on the first reply of a resumed session is not heard: - // TruncateLastReply finds no ordinal to cut at and returns nothing, and the provider reports - // that as a write it declined rather than as a cut it lost. RestoreLastAssistantOrdinal(); } @@ -126,7 +123,7 @@ public bool CanTruncateFrom(string? parentMessageId) int last = int.MinValue; foreach (StoredMessage stored in Messages) { - if (stored.Ordinal < fromOrdinal || stored.CoversUpTo is not null) + if (!Withdrawn(stored, fromOrdinal)) { continue; } @@ -140,12 +137,23 @@ public bool CanTruncateFrom(string? parentMessageId) return null; } - _ = Messages.RemoveAll(stored => stored.Ordinal >= fromOrdinal && stored.CoversUpTo is null); + _ = Messages.RemoveAll(stored => Withdrawn(stored, fromOrdinal)); RestoreLastAssistantOrdinal(); Revision++; return new WithdrawnTurns(first, last); } + /// The ids of the rows withdraws from on. + public IReadOnlyList IdsFrom(int fromOrdinal) + { + return [.. Messages.Where(stored => Withdrawn(stored, fromOrdinal)).Select(static stored => stored.MessageId)]; + } + + private static bool Withdrawn(StoredMessage stored, int fromOrdinal) + { + return stored.Ordinal >= fromOrdinal && stored.CoversUpTo is null; + } + private void RestoreLastAssistantOrdinal() { LastAssistantOrdinal = Messages @@ -286,46 +294,61 @@ public IReadOnlyList Append( } /// Cuts the turn the caller was hearing down to the words the caller actually heard. - public IReadOnlyList TruncateLastReply(string heard) + /// The words the caller heard, across every step of the turn's reply. + /// The turn the last reply must belong to, or for whichever turn it is. + /// + /// The rows rewritten in place, and the rows removed because the cut left them carrying nothing, by the + /// same rule a running turn's cut drops them (). + /// + public ReplyRewrite RewriteReply(string heard, int? turnIndex = null) { ArgumentNullException.ThrowIfNull(heard); if (LastAssistantOrdinal is not int ordinal - || Messages.Find(message => message.Ordinal == ordinal) is not { } spoken) + || Messages.Find(message => message.Ordinal == ordinal) is not { } spoken + || (turnIndex is { } wanted && spoken.TurnIndex != wanted)) { - return []; + return new ReplyRewrite([], []); } + List replies = Messages.FindAll(stored => + stored.TurnIndex == spoken.TurnIndex && stored.Message.Role == ChatRole.Assistant && stored.CoversUpTo is null); + List laid = ShownWords.Lay([.. replies.Select(stored => stored.Message)], heard); + List rows = []; - foreach (StoredMessage stored in Messages) + List removed = []; + for (int index = 0; index < replies.Count; index++) { - if (stored.TurnIndex != spoken.TurnIndex || stored.Message.Role != ChatRole.Assistant || stored.CoversUpTo is not null) + StoredMessage stored = replies[index]; + if (ReferenceEquals(laid[index], stored.Message) && stored.Ordinal != ordinal) { continue; } - bool isReply = stored.Ordinal == ordinal; - if (!isReply && !stored.Message.Contents.Any(content => content is TextContent)) + ConversationMessage row = new(ConversationId, stored.Ordinal, stored.TurnIndex, laid[index], stored.MessageId); + if (!TurnWords.Carries(laid[index])) { + _ = Messages.Remove(stored); + removed.Add(row); continue; } - List kept = [.. stored.Message.Contents.Where(content => content is not TextContent)]; - - ChatMessage corrected = stored.Message.Clone(); - corrected.Contents = isReply && heard.Length > 0 ? [new TextContent(heard), .. kept] : kept; - stored.Message = corrected; + stored.Message = laid[index]; + rows.Add(row); + } - rows.Add(new ConversationMessage( - ConversationId, stored.Ordinal, stored.TurnIndex, corrected, stored.MessageId)); + ReplyRewrite rewrite = new(rows, removed); + if (removed.Count > 0) + { + RestoreLastAssistantOrdinal(); } - if (rows.Count > 0) + if (rewrite.Changed) { Revision++; } - return rows; + return rewrite; } /// Mints a name for a row the caller had no name for. diff --git a/src/AgentCore.Application/Transcript/ConversationWrites.cs b/src/AgentCore.Application/Transcript/ConversationWrites.cs new file mode 100644 index 00000000..774b8cd6 --- /dev/null +++ b/src/AgentCore.Application/Transcript/ConversationWrites.cs @@ -0,0 +1,191 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Ports; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace AgentCore.Application.Transcript +{ + /// + /// The durable writes of a conversation: each changes the session's transcript under its lock and queues the + /// matching store write in the same step, so the store sees the writes in the order the transcript took them. + /// + /// Creates the writes over the sessions' gates. + /// The gate of every session. + /// Store 1, which an edit under the summary cuts directly. + /// Where cuts and withdrawals are logged. + internal sealed class ConversationWrites(ConversationGates gates, IConversationStore store, ILogger logger) + { + private readonly ConversationGates _gates = gates; + + private readonly IConversationStore _store = store; + + private readonly ILogger _logger = logger; + + /// Appends one sealed turn, taking everything staged for it. + /// + /// The ids of the user's message and of the last message, which is null when the user's is the only one, the words + /// the rows say, and the append's outcome. + /// + public TurnWrite Commit(AgentSession session, TurnCommit commit) + { + return _gates.Under( + session, + (transcript, gate) => + { + IReadOnlyList rows = transcript.Append( + TurnWords.Compose(commit, gate.TakeStaged()), commit.UserMessageId); + Task refused = gate.QueueAppend(rows, commit.State); + return new TurnWrite( + rows[0].MessageId, + rows.Count > 1 ? rows[^1].MessageId : null, + TurnWords.Spoken(rows.Select(row => row.Content)), + refused); + }); + } + + /// Rewrites the last reply to , when it belongs to . + /// when a row was rewritten or removed. + public bool Rewrite(AgentSession session, int turnIndex, string text) + { + return _gates.Under( + session, + (transcript, gate) => + { + ReplyRewrite rewrite = transcript.RewriteReply(text, turnIndex); + if (!rewrite.Changed) + { + return false; + } + + gate.QueueRewrite(rewrite.Rewritten); + gate.QueueRemove(rewrite.Removed); + return true; + }); + } + + /// Withdraws every row after , or every row when it is null. + /// The turns withdrawn, or when nothing went. + public async ValueTask TruncateFromAsync(AgentSession session, string? parentMessageId, CancellationToken cancellationToken) + { + ITranscriptLossCounter? losses = _gates.Under(session, static (_, gate) => gate.Losses); + + // Judged before the truncate, which may delete every row of an append the store took and still reported lost. + TranscriptLossVerdict? verdict = losses is null + ? null + : await losses.JudgeAsync(session, cancellationToken).ConfigureAwait(false); + + WithdrawnTurns? withdrawn = _gates.Under( + session, + (transcript, gate) => + { + int from; + if (parentMessageId is null) + { + from = 0; + } + else if (transcript.OrdinalOf(parentMessageId) is { } parent) + { + from = parent + 1; + } + else + { + return (WithdrawnTurns?)null; + } + + IReadOnlyList ids = transcript.IdsFrom(from); + if (transcript.TruncateFrom(from) is not { } taken) + { + return null; + } + + Log.ConversationTruncated(_logger, transcript.ConversationId, from, transcript.TurnIndex); + + gate.QueueTruncate(from, ids); + return taken; + }); + + if (withdrawn is not null && verdict is { } judged && losses is not null) + { + losses.Realigned(judged); + } + + return withdrawn; + } + + /// + /// Withdraws every row after in the store itself, once every queued write + /// has landed, and reads the rows back into the session: the summary stands in for rows the session no longer holds. + /// + /// The turns withdrawn, or when the store holds no message of that name. + public async ValueTask CutUnderSummaryAsync( + AgentSession session, string? parentMessageId, CancellationToken cancellationToken) + { + await _gates.Writes(session).ConfigureAwait(false); + + (string conversationId, int turnIndex, ITranscriptLossCounter? losses) = _gates.Under( + session, static (transcript, gate) => (transcript.ConversationId, transcript.TurnIndex, gate.Losses)); + + int from; + if (parentMessageId is null) + { + from = 0; + } + else if (await _store.OrdinalOfAsync(conversationId, parentMessageId, cancellationToken).ConfigureAwait(false) is { } parent) + { + from = parent + 1; + } + else + { + return null; + } + + // Judged before the cut, which may delete rows the store took and still reported lost. + TranscriptLossVerdict? verdict = losses is null + ? null + : await losses.JudgeAsync(session, cancellationToken).ConfigureAwait(false); + + ConversationCut cut = await _store.TruncateAsync(conversationId, from, cancellationToken).ConfigureAwait(false); + Log.ConversationTruncated(_logger, conversationId, from, turnIndex); + + ConversationRecord refreshed = await _store.GetAsync(conversationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException( + $"Store 0 holds no conversation '{conversationId}' for its own session to edit."); + IReadOnlyList rows = await _store.ReadForSessionAsync(conversationId, cancellationToken).ConfigureAwait(false); + + _ = _gates.Under( + session, + (transcript, _) => + { + transcript.Resync(rows, refreshed.NextOrdinal); + return true; + }); + + if (verdict is { } judged && losses is not null) + { + losses.Realigned(judged); + } + + return cut.Turns; + } + + /// Puts one summary in place of every row at or below , unless the words moved since . + /// when the summary now stands. + public bool Compact(AgentSession session, ChatMessage summary, int coversUpTo, int revision) + { + return _gates.Under( + session, + (transcript, gate) => + { + if (transcript.Compact(summary, coversUpTo, revision) is not { } row) + { + return false; + } + + _ = gate.QueueAppend([row], state: null); + return true; + }); + } + } +} diff --git a/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs b/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs new file mode 100644 index 00000000..b8e48f42 --- /dev/null +++ b/src/AgentCore.Application/Transcript/ITranscriptLossCounter.cs @@ -0,0 +1,43 @@ +using Microsoft.Agents.AI; + +namespace AgentCore.Application.Transcript +{ + /// + /// Counts the store writes of one session that the store did not take, so a later read of the store can tell + /// them from another session's writes. + /// + internal interface ITranscriptLossCounter + { + /// Reports one dropped store 1 write, and counts it lost. Never throws. + /// The turn the transcript was on when the write was queued. + /// + /// The ids of the rows the write would have appended, or none for a write that changes rows in place. The store + /// may still hold them: a write can land and still fail to say so. + /// + /// Why the store did not take the write. + void Dropped(int turnIndex, IReadOnlyList appended, Exception exception); + + /// + /// Records that the session is deleting rows itself. A lost append that landed no longer holds them, and is judged + /// by the rows it still holds. Called in the write queue, after every write queued before the delete. Never throws. + /// + /// The ids of the rows the session deletes. + void Withdrew(IReadOnlyList messageIds); + + /// + /// Judges the losses counted so far against what the store holds, before an edit cuts rows the judgement reads, or + /// realigns the session's words and ordinals with the store. After that, the store no longer says which of the + /// session's writes it lost. + /// + /// The session of this conversation. + /// Cancels the reads. + /// The verdict, or when nothing was lost or the store cannot be read. + ValueTask JudgeAsync(AgentSession session, CancellationToken cancellationToken); + + /// + /// Records that the edit was judged for is done: the verdict now stands for every loss it judged. + /// + /// What returned. + void Realigned(TranscriptLossVerdict verdict); + } +} diff --git a/src/AgentCore.Application/Transcript/ReplyRewrite.cs b/src/AgentCore.Application/Transcript/ReplyRewrite.cs new file mode 100644 index 00000000..6ac50248 --- /dev/null +++ b/src/AgentCore.Application/Transcript/ReplyRewrite.cs @@ -0,0 +1,11 @@ +namespace AgentCore.Application.Transcript +{ + /// What one rewrite of a committed reply did to its rows. + /// The rows whose content changed in place. + /// The rows the cut left carrying nothing, which the conversation no longer holds. + internal sealed record ReplyRewrite(IReadOnlyList Rewritten, IReadOnlyList Removed) + { + /// Gets whether the rewrite changed any row. + public bool Changed => Rewritten.Count > 0 || Removed.Count > 0; + } +} diff --git a/src/AgentCore.Application/Transcript/ShownWords.cs b/src/AgentCore.Application/Transcript/ShownWords.cs new file mode 100644 index 00000000..a8ae8453 --- /dev/null +++ b/src/AgentCore.Application/Transcript/ShownWords.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Transcript +{ + /// Lays the text a user was shown back over the messages of the reply that produced it. + internal static class ShownWords + { + /// Cuts the text of down to , in order. + /// The messages of one reply, oldest first. + /// Every word of the reply the user saw or heard, across all its steps. + /// + /// One message per input, in the same order: the same instance when its text stands, a copy when its text + /// was cut. Each message keeps its text while still starts with it; the first that + /// does not, or the last message with text, takes the rest, and every later message loses its text. When no + /// message has text, the shown text is added as one assistant message at the end. Trimmed at both ends. + /// + public static List Lay(IList messages, string shown) + { + ArgumentNullException.ThrowIfNull(messages); + ArgumentNullException.ThrowIfNull(shown); + + int last = -1; + for (int index = 0; index < messages.Count; index++) + { + if (HasText(messages[index])) + { + last = index; + } + } + + List laid = new(messages.Count + 1); + string rest = shown; + for (int index = 0; index < messages.Count; index++) + { + ChatMessage message = messages[index]; + if (!HasText(message)) + { + laid.Add(message); + continue; + } + + string text = message.Text; + if (index < last && rest.StartsWith(text, StringComparison.Ordinal)) + { + laid.Add(message); + rest = rest[text.Length..]; + continue; + } + + laid.Add(WithText(message, rest)); + rest = string.Empty; + } + + if (last < 0 && shown.Length > 0) + { + laid.Add(new ChatMessage(ChatRole.Assistant, shown)); + } + + TrimEnds(laid); + return laid; + } + + private static void TrimEnds(List laid) + { + int first = laid.FindIndex(HasText); + if (first >= 0) + { + laid[first] = WithText(laid[first], laid[first].Text.TrimStart()); + } + + int last = laid.FindLastIndex(HasText); + if (last >= 0) + { + laid[last] = WithText(laid[last], laid[last].Text.TrimEnd()); + } + } + + private static bool HasText(ChatMessage message) + { + return message.Contents.Any(content => content is TextContent { Text.Length: > 0 }); + } + + private static ChatMessage WithText(ChatMessage message, string text) + { + if (message.Text == text) + { + return message; + } + + int at = message.Contents.ToList().FindIndex(content => content is TextContent); + List contents = [.. message.Contents.Where(content => content is not TextContent)]; + if (text.Length > 0) + { + contents.Insert(Math.Max(at, 0), new TextContent(text)); + } + + ChatMessage cut = message.Clone(); + cut.Contents = contents; + return cut; + } + } +} diff --git a/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs b/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs new file mode 100644 index 00000000..90380ff3 --- /dev/null +++ b/src/AgentCore.Application/Transcript/TranscriptCatchUp.cs @@ -0,0 +1,24 @@ +using AgentCore.Application.Conversation; + +namespace AgentCore.Application.Transcript +{ + /// What store 1 held beyond a session's words, read without changing them. + /// The transcript's revision when the read began. The rows apply only while it stands. + /// The rows store 1 read for the session. + /// The next free ordinal of the conversation, from store 0's own counter. + /// The state store 0 holds, or when it holds none. + internal sealed record TranscriptCatchUp(int Revision, IReadOnlyList Rows, int NextOrdinal, ConversationSessionState? State) + { + /// Gets the index the conversation's next turn takes, from the state store 0 holds. + public int NextTurnIndex => State?.NextTurnIndex ?? 0; + + /// Gets the last of the session's lost writes counted before the read. A catch-up that takes these rows clears it and every one before it. + public int LossMark { get; init; } + + /// + /// Gets whether the store lost a write of this session and holds another session's writes in its place. The + /// session's own state then no longer follows from what the store holds. + /// + public bool Overtaken { get; init; } + } +} diff --git a/src/AgentCore.Application/Transcript/TranscriptLoss.cs b/src/AgentCore.Application/Transcript/TranscriptLoss.cs new file mode 100644 index 00000000..9e468f05 --- /dev/null +++ b/src/AgentCore.Application/Transcript/TranscriptLoss.cs @@ -0,0 +1,19 @@ +namespace AgentCore.Application.Transcript +{ + /// One store 1 write of a session that the store did not take, or did not say it took. + /// Where the loss falls among every loss the session counted, from 1. + /// The ids of the rows the write appends, or none for a write that changes rows in place. + /// Whether the store refused the write because another session saved its turn first. + /// + /// Whether this marks a judgement, not a write: when an edit cut the rows the losses before this one were judged by, + /// the store held another session's writes in their place. + /// + internal sealed record TranscriptLoss(int Sequence, IReadOnlyList Appended, bool Refused, bool Overtaken = false) + { + /// + /// Gets the ids of the session has not deleted itself since. Had the append landed, the + /// store would hold exactly these of its rows. + /// + public IReadOnlyList Held { get; init; } = Appended; + } +} diff --git a/src/AgentCore.Application/Transcript/TranscriptLossVerdict.cs b/src/AgentCore.Application/Transcript/TranscriptLossVerdict.cs new file mode 100644 index 00000000..1d0caee4 --- /dev/null +++ b/src/AgentCore.Application/Transcript/TranscriptLossVerdict.cs @@ -0,0 +1,7 @@ +namespace AgentCore.Application.Transcript +{ + /// What a session's lost writes came to, judged against one read of the store. + /// The last loss counted before the read. Later losses are not judged. + /// Whether the store held another session's writes in place of the losses. + internal readonly record struct TranscriptLossVerdict(int Mark, bool Overtaken); +} diff --git a/src/AgentCore.Application/Transcript/TranscriptLosses.cs b/src/AgentCore.Application/Transcript/TranscriptLosses.cs new file mode 100644 index 00000000..72f391af --- /dev/null +++ b/src/AgentCore.Application/Transcript/TranscriptLosses.cs @@ -0,0 +1,112 @@ +namespace AgentCore.Application.Transcript +{ + /// + /// The store 1 writes of one session that the store did not take, since the session last caught up with it. + /// + /// + /// A catch-up clears the losses counted up to the mark it read before its store read, not every loss: a write the + /// store drops while a catch-up is in flight stays counted for the next one. + /// + internal sealed class TranscriptLosses + { + private readonly Lock _gate = new(); + + // Both guarded by _gate. + private readonly List _lost = []; + + private int _counted; + + /// Gets whether a write was lost since the session last caught up. + internal bool Any + { + get + { + lock (_gate) + { + return _lost.Count > 0; + } + } + } + + /// Gets whether the store refused a turn since the session last caught up. + internal bool Refused + { + get + { + lock (_gate) + { + return _lost.Exists(static loss => loss.Refused); + } + } + } + + /// Counts one write the store did not take. + /// The ids of the rows it would have appended. + /// Whether the store refused it because another session saved its turn first. + internal void Lose(IReadOnlyList appended, bool refused) + { + lock (_gate) + { + _lost.Add(new TranscriptLoss(++_counted, appended, refused)); + } + } + + /// Records that the session deleted rows itself, so no loss counts them missing. + /// The ids of the rows deleted. + internal void Withdrew(IReadOnlyList messageIds) + { + lock (_gate) + { + for (int index = 0; index < _lost.Count; index++) + { + TranscriptLoss loss = _lost[index]; + if (loss.Held.Any(messageIds.Contains)) + { + _lost[index] = loss with { Held = [.. loss.Held.Where(id => !messageIds.Contains(id))] }; + } + } + } + } + + /// Reads the losses as they stand. + /// + /// The mark to hand once the session took what the store held, and every loss since the + /// last catch-up, oldest first. + /// + internal (int Mark, IReadOnlyList Since) Read() + { + lock (_gate) + { + return (_counted, [.. _lost]); + } + } + + /// Records that the session took what the store held when was read. + /// What returned before that store read. + internal void CaughtUp(int mark) + { + lock (_gate) + { + _ = _lost.RemoveAll(loss => loss.Sequence <= mark); + } + } + + /// + /// Records that an edit judged by is done: it cut rows the losses up to the verdict's + /// mark were judged by, or realigned the session's words and ordinals with the store. The verdict stands for those + /// losses from then on. A refusal and an overtaking stay counted: only a catch-up that takes the stored state + /// clears them. + /// + internal void Realigned(TranscriptLossVerdict verdict) + { + lock (_gate) + { + _ = _lost.RemoveAll(loss => loss.Sequence <= verdict.Mark && !loss.Refused && !loss.Overtaken); + if (verdict.Overtaken) + { + _lost.Add(new TranscriptLoss(++_counted, [], Refused: false, Overtaken: true)); + } + } + } + } +} diff --git a/src/AgentCore.Application/Transcript/TranscriptPosition.cs b/src/AgentCore.Application/Transcript/TranscriptPosition.cs new file mode 100644 index 00000000..1cb5022b --- /dev/null +++ b/src/AgentCore.Application/Transcript/TranscriptPosition.cs @@ -0,0 +1,8 @@ +namespace AgentCore.Application.Transcript +{ + /// Where a session's words stood at one moment, read under the conversation's lock. + /// Completes when every store 1 write queued up to that moment has landed. + /// The transcript's revision, which every later change of the words moves. + /// The next ordinal as the session counted it. + internal readonly record struct TranscriptPosition(Task Written, int Revision, int NextOrdinal); +} diff --git a/src/AgentCore.Application/Transcript/TurnCommit.cs b/src/AgentCore.Application/Transcript/TurnCommit.cs new file mode 100644 index 00000000..52185736 --- /dev/null +++ b/src/AgentCore.Application/Transcript/TurnCommit.cs @@ -0,0 +1,46 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Runtime; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Transcript +{ + /// One turn as it is sealed: the one durable append of that turn. + /// The message the user sent. It is always written first. + /// Design: docs/handoff/2026-09-22-maf-native-engine-design.md, section 2 "Types" and section 3. + internal sealed record TurnCommit(ChatMessage User) + { + /// Gets what the run yielded as the caller saw it, or when it yielded nothing. + public AgentResponse? Seen { get; init; } + + /// Gets what the turn layers reported about the run, or when no layer marked it. + public TurnDisposition? Disposition { get; init; } + + /// Gets the cut that stopped the turn, or when nothing cut it. + public TurnCut? Cut { get; init; } + + /// + /// Gets whether the run answered in full: nothing cut it and no fallback replaced its reply. A turn that did + /// not keeps only its finished tool pairs and . + /// + public bool Completed { get; init; } = true; + + /// + /// Gets whether the conversation keeps only what the user said and was shown, as a graph row does: its + /// nodes' tool pairs and lines to each other are neither. + /// + public bool CallerFacing { get; init; } + + /// + /// Gets the line the user was shown, when the turn is caller-facing or did not complete, or + /// to read it from and . + /// + public string? Reply { get; init; } + + /// Gets the state that rides the same batch as the words, or for none. + public ConversationSessionState? State { get; init; } + + /// Gets what the caller calls , or to name it in the append. + public string? UserMessageId { get; init; } + } +} diff --git a/src/AgentCore.Application/Transcript/TurnWords.cs b/src/AgentCore.Application/Transcript/TurnWords.cs new file mode 100644 index 00000000..290b3fc8 --- /dev/null +++ b/src/AgentCore.Application/Transcript/TurnWords.cs @@ -0,0 +1,92 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Transcript +{ + /// Decides the messages one sealed turn writes, by the Cut rule of design section 3. + internal static class TurnWords + { + /// Builds the messages of one turn, the user's first. + /// The turn being sealed. + /// Every message the framework staged for the turn, oldest first. + /// + /// For a caller-facing turn: the line the user was shown, when there is one. For a cut turn: every step's + /// words and finished tool pairs in the order they ran, the words cut to what was shown (see + /// ). For a turn that failed without a cut: every step's shown words and finished tool + /// pairs in the order they ran, then the fallback line as its own message, written once. Otherwise everything + /// staged, or what the caller saw when nothing was staged. + /// + public static List Compose(TurnCommit commit, List staged) + { + List words = [commit.User]; + + // A cancelled or faulted run stages nothing (probes P03, P06), so what the caller saw is the fuller record. + IList source = commit.Seen?.Messages ?? staged; + + if (commit.CallerFacing) + { + string heard = commit.Reply ?? commit.Cut?.ShownText ?? ReplyText.From(source); + if (heard.Length > 0) + { + words.Add(new ChatMessage(ChatRole.Assistant, heard)); + } + + return words; + } + + if (commit.Cut is null && commit.Completed) + { + words.AddRange(staged.Count > 0 ? staged : source); + return words; + } + + List finished = TurnMessages.FinishedMessages(source); + string all = string.Concat(finished.Select(message => message.Text)); + + if (commit.Cut is { } cut) + { + words.AddRange(ShownWords.Lay(finished, cut.ShownText ?? all).Where(Carries)); + return words; + } + + // The fallback layer yields its line on the same stream, so the words seen end with it; it is laid + // off the steps and written once, as its own message. + string fallback = (commit.Reply ?? ReplyText.From(source)).Trim(); + string seen = all.TrimEnd(); + string before = fallback.Length > 0 && seen.EndsWith(fallback, StringComparison.Ordinal) + ? seen[..^fallback.Length] + : all; + + words.AddRange(ShownWords.Lay(finished, before).Where(Carries)); + if (fallback.Length > 0) + { + words.Add(new ChatMessage(ChatRole.Assistant, fallback)); + } + + return words; + } + + /// + /// Reads the words a turn's messages say, as the store's verify query rebuilds them from the rows: the text parts + /// of every assistant message, in order, with no separator. The turn's replyTextSha256 and + /// utteranceUntilInterruptSha256 hash this, so a hash and the rows it proves cannot drift apart. + /// + /// The messages built for the turn, or the rows written from them. + /// The words, or an empty string when no assistant message carries text. + public static string Spoken(IEnumerable messages) + { + return string.Concat(messages + .Where(message => message.Role == ChatRole.Assistant) + .SelectMany(message => message.Contents.OfType()) + .Select(text => text.Text)); + } + + /// Whether a message still says or does anything once its words are cut. + internal static bool Carries(ChatMessage message) + { + return message.Contents.Any(content => content + is FunctionCallContent or FunctionResultContent or ToolApprovalRequestContent or TextContent { Text.Length: > 0 }); + } + } +} diff --git a/src/AgentCore.Application/Transcript/TurnWrite.cs b/src/AgentCore.Application/Transcript/TurnWrite.cs new file mode 100644 index 00000000..70d90ce5 --- /dev/null +++ b/src/AgentCore.Application/Transcript/TurnWrite.cs @@ -0,0 +1,16 @@ +namespace AgentCore.Application.Transcript +{ + /// One turn's commit: the ids its append wrote, and whether the store kept them. + /// What the user's message was written under. + /// What the last message was written under, or when the user's message is all the turn wrote. + /// The words the append's rows say, as reads them. + /// + /// Completes once the append landed or was dropped: when the store refused it because the + /// conversation already saved this turn. + /// + internal sealed record TurnWrite(string UserMessageId, string? ReplyMessageId, string Spoken, Task Refused) + { + /// Gets the two ids together. + public (string UserMessageId, string? ReplyMessageId) Ids => (UserMessageId, ReplyMessageId); + } +} diff --git a/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj b/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj index 7072d7e7..a6981477 100644 --- a/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj +++ b/src/AgentCore.AspNetCore/AgentCore.AspNetCore.csproj @@ -32,4 +32,13 @@ + + + + + + diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSweepOptions.cs b/src/AgentCore.AspNetCore/Conversation/ConversationSweepOptions.cs deleted file mode 100644 index 6ad3138f..00000000 --- a/src/AgentCore.AspNetCore/Conversation/ConversationSweepOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace AgentCore.AspNetCore.Conversation -{ - /// Settings for . - public sealed class ConversationSweepOptions - { - /// How long a conversation is kept after its last activity, before the sweep erases it. - public TimeSpan Retention { get; set; } = TimeSpan.FromDays(90); - - /// How often the sweep runs. - public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1); - - /// How many conversations one sweep pass erases at most. - public int BatchSize { get; set; } = 500; - } -} diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs b/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs index c287bcd0..29a974a5 100644 --- a/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs +++ b/src/AgentCore.AspNetCore/Conversation/ConversationSweeper.cs @@ -1,3 +1,4 @@ +using AgentCore.AspNetCore.DependencyInjection; using AgentCore.Application.Ports; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -7,34 +8,38 @@ namespace AgentCore.AspNetCore.Conversation { /// - /// Runs the retention sweep of for as long as the host is up. + /// Runs the retention sweep of response ids for as long as the host is up. /// - /// - /// AgentCore never registers this on its own: a host that wants old conversations erased opts in by - /// calling AddConversationSweep. Without that call, conversations are kept forever. - /// public sealed class ConversationSweeper( IServiceProvider services, - IOptions options, + IOptions options, TimeProvider timeProvider, ILogger logger) : BackgroundService { + private static readonly TimeSpan Interval = TimeSpan.FromHours(1); + + private const int BatchSize = 500; + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - ConversationSweepOptions settings = options.Value; + TimeSpan? retention = options.Value.ResponseRetention; + + if (retention is null) + { + return; + } - // Hosted service is constructed before AgentCore's own boot has not finished. IConversations conversations = services.GetRequiredService(); - using PeriodicTimer timer = new(settings.Interval, timeProvider); + using PeriodicTimer timer = new(Interval, timeProvider); while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) { try { int swept = await conversations - .SweepAsync(settings.Retention, settings.BatchSize, stoppingToken) + .SweepAsync(retention.Value, BatchSize, stoppingToken) .ConfigureAwait(false); ConversationSweeperLog.Swept(logger, swept); @@ -52,11 +57,11 @@ internal static partial class ConversationSweeperLog { /// One sweep pass finished. /// The logger of the sweeper. - /// How many conversations the pass erased. + /// How many response ids the pass deleted. [LoggerMessage( EventId = 1, Level = LogLevel.Debug, - Message = "the retention sweep erased {Count} conversation(s).")] + Message = "the retention sweep deleted {Count} response id(s).")] public static partial void Swept(ILogger logger, int count); /// A sweep pass could not finish. diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationTurnArbiter.cs b/src/AgentCore.AspNetCore/Conversation/ConversationTurnArbiter.cs deleted file mode 100644 index 19247f47..00000000 --- a/src/AgentCore.AspNetCore/Conversation/ConversationTurnArbiter.cs +++ /dev/null @@ -1,423 +0,0 @@ -using AgentCore.Application.Ports; -using Microsoft.Extensions.AI; - -namespace AgentCore.AspNetCore.Conversation -{ - /// - /// Decides which turn of one conversation may speak, and which one a barge-in cuts short. - /// - /// - /// - /// The first of the two barge-in gates lives here: once a turn is marked interrupted this class - /// stops handing its fragments to . The last gate, immediately - /// before the wire, belongs to the port's own implementation and is expressed in that - /// implementation's private vocabulary, so no turn id of this class ever crosses the port. - /// - /// - /// Builds the arbiter one conversation runs its turns through. - /// - /// Where a turn's reply goes, one fragment at a time. - /// Watches a turn's task, and never lets its fault go unobserved. - /// - /// Logs that one prompt was held until the turn in flight ends, given the id of the conversation it was - /// held for. Called once per held prompt, from inside the turn lock. - /// - /// - /// Logs that a further prompt was dropped because one was already held, given the id of the conversation - /// it was dropped for. Called at most once per connection, from inside the turn lock. - /// - /// - /// Cancelled once, for any reason the transport is going away. Every turn reads it, and no - /// held prompt starts a turn after it fires. - /// - internal sealed class ConversationTurnArbiter( - IConversationPort initialSession, - IConversationOutputPort output, - ConnectionTaskObserver observer, - Action logPromptHeld, - Action logPendingPromptDropped, - CancellationToken connectionToken) - { - // Not readonly, and volatile for the same reason the connection's own session field is: a - // repeated setup frame replaces the conversation this arbiter runs turns against, and every turn that - // starts after it must run against the replacement. One arbiter serves the whole connection, - // across as many sessions as the vendor names, because "one turn at a time" is a property of - // the transport and not of any one session: a second arbiter would carry its own _turnActive, - // its own held prompt, and its own ids, and a prompt arriving while the replaced session's turn - // still ran would start a second turn writing into the same channel. - private volatile IConversationPort _session = initialSession; - private readonly IConversationOutputPort _output = output; - private readonly ConnectionTaskObserver _observer = observer; - private readonly Action _logPromptHeld = logPromptHeld; - private readonly Action _logPendingPromptDropped = logPendingPromptDropped; - private readonly CancellationToken _connectionToken = connectionToken; - - private Task _turn = Task.CompletedTask; - private bool _loggedPendingPromptDropped; - - // Guards _turnId, _interruptedTurnId, _turnActive, _pendingPrompt, and every place _turn is - // read then reassigned. The read loop reaches this lock through StartTurnAsync and - // Interrupt, one frame at a time; a turn's own task reaches it too, from - // RunPendingPrompt, once it finishes. _turnId and _interruptedTurnId are also read without the - // lock, through Interlocked.Read, from inside a turn's own hot loop, where taking a lock on - // every update would slow the path this guard exists to protect. - private readonly Lock _turnLock = new(); - private long _turnId; - - // The id a barge-in invalidated, or 0 when nothing has been interrupted yet. This is a - // separate field from _turnId, not a reuse of it, because _turnId also advances on every - // ordinary turn transition — RunPendingPrompt mints the next id as soon as one turn's own task - // finishes, which can race ahead of the write loop still sending that turn's own trailing - // frames. A single shared "current id" field cannot tell "a newer turn has started normally" - // apart from "this turn was cut short": both look like a mismatch to a check that only asks - // "does this item's id equal the current one." Recording which id was interrupted, instead of - // just raising a shared counter, is what lets a turn's own guard ask the question that - // actually matters: was *this* turn, specifically, the one barge-in cut off, regardless of how - // many ordinary turns have started since. - private long _interruptedTurnId; - - // The newest turn whose output this arbiter has actually handed to the output port, or 0 while - // no turn has produced a word. It is not _turnId, and the difference is the whole of the - // barge-in target: the transport paces the audio itself, so turn N is still being spoken after - // its own stream ended, and RunPendingPrompt starts turn N+1 inside turn N's own finally. A - // barge-in in that window belongs to turn N — the turn the caller was hearing — and marking - // N+1 instead would drop every update of a turn nobody has heard yet and skip its closing - // last: true frame, leaving the caller with dead air and the vendor with an unfinished reply. - private long _spokenTurnId; - private bool _turnActive; - private string? _pendingPrompt; - - /// What a voice caller hears when a tool waits on an approval the conversation cannot take. - internal const string PendingApprovalNotice = - "That action needs a human approval, which this conversation can't take."; - - /// Gets the turn running now, or the one that ran last. - /// - /// Read under the same lock every writer takes. can reassign the - /// turn from a turn's own task, off the read loop, so teardown is not the only writer's own - /// reader: without the lock it could observe a stale reference and let the real last turn - /// finish unobserved. itself checks the connection token before - /// it will start a turn from a held prompt, which is what stops the reverse case — a fresh turn - /// appearing here after teardown already read it. - /// - public Task CurrentTurn - { - get - { - lock (_turnLock) - { - return _turn; - } - } - } - - /// Points this arbiter at another conversation, keeping every turn it is already running. - /// The conversation every turn started from now on runs against. - /// - /// For the one caller that needs it: a transport whose vendor names the conversation a second time on - /// one connection. Only the session moves. _turnActive, the held prompt, the ids, and the - /// log-once flag all stay exactly where they are, because "one turn at a time" and "one held - /// prompt, logged once" are promises this connection makes for its whole life and not for one - /// session of it. A turn already in flight keeps the session it started on, the same way it - /// would have before this method existed. - /// - public void Rebind(IConversationPort session) - { - _session = session; - } - - /// Starts a turn for what the caller just said, or holds it until the turn in flight ends. - /// What the caller said. - /// - /// The turn this conversation started, or a completed task when the prompt was held or dropped. It - /// reports only completion: a turn's own fault stays on , which the - /// next turn and teardown both already observe through . - /// - public Task StartTurnAsync(string text) - { - // Read once, here, so the turn this conversation starts and the line it may log about holding the - // prompt both name the conversation the setup frame most recently gave this connection. - IConversationPort session = _session; - - Task justFinished; - lock (_turnLock) - { - if (_turnActive) - { - // The caller finished a second sentence before the agent spoke. There is no - // interrupt frame for this, because the vendor never truncated anything, so there - // is no heard text to record. IConversationPort throws on a second turn of one - // conversation, and a throw here would drop it, so one prompt is held and runs once the - // turn in flight ends. Item 6a of section 11 draws the line at one: a queue of - // held prompts would answer questions the caller has already moved past, so a - // third prompt is dropped instead of queued behind the first. - // - // _turnActive, not _turn.IsCompleted: RunPendingPrompt runs inside RunTurnAsync's - // own finally, strictly before that turn's Task itself transitions to completed, so - // a check against IsCompleted here could see "no turn running" for a conversation whose own - // RunPendingPrompt had already run and found nothing pending — losing this prompt - // outright, since nothing would ever come back to run it. _turnActive is cleared by - // RunPendingPrompt in that same lock acquisition instead, so this check and that - // clear can never miss each other. - if (_pendingPrompt is null) - { - _pendingPrompt = text; - _logPromptHeld(session.ConversationId); - } - else if (!_loggedPendingPromptDropped) - { - // Log once for the conversation, not once for the frame. Same rule as the - // unknown-frame line. - _loggedPendingPromptDropped = true; - _logPendingPromptDropped(session.ConversationId); - } - - return Task.CompletedTask; - } - - justFinished = _turn; - _turnActive = true; - } - - return StartAfterAsync(session, text, justFinished); - } - - /// Ends the running turn where the caller cut the reply off, and stops the audio behind it. - /// The text the caller actually heard, as the transport reported it. - /// How much of the reply played, as the transport reported it. - /// - /// when the conversation recorded the barge-in, against the running turn or - /// against the turn that finished last, and when there was nothing to - /// record it against. - /// - /// - /// Synchronous, and it stays synchronous. It marks the interrupted id, decides - /// cutsRunningTurn, and calls before anything - /// awaits, and it hands 's task to the observer rather - /// than awaiting it: this runs on the read loop, and a read loop that blocks cannot receive the - /// next frame. - /// - public bool Interrupt(string heardText, TimeSpan playedDuration) - { - // Read once, here, so the record goes to the conversation the setup frame most recently named — the - // same field, read at the same point, as when this method still lived on the connection. - IConversationPort session = _session; - - bool cutsRunningTurn; - lock (_turnLock) - { - // Mark before cancelling. Every project that solves this race does it in this order: - // a token already past the guard in RunTurnAsync must find its own id already marked - // interrupted right here, not a cancellation that is still on its way to the model - // conversation. - // - // _spokenTurnId is what is marked, and never _turnId. The two differ exactly when a - // held prompt fired: turn N finishes streaming, RunPendingPrompt starts turn N+1 inside - // turn N's own finally, and the transport is still speaking turn N. _turnId then names - // N+1, a turn the caller has not heard one word of, and marking it would silence it and - // skip its closing frame. _spokenTurnId names the turn whose output actually reached - // the transport, which is the turn the caller was hearing and the only one a barge-in - // may cut. - // - // Zero means no turn has produced a word yet, so there is nothing to cut short and the - // mark stays where it was. - long spoken = Interlocked.Read(ref _spokenTurnId); - if (spoken != 0) - { - _ = Interlocked.Exchange(ref _interruptedTurnId, spoken); - } - - // The same comparison the mark above rests on, carried across the seam. ConversationSession - // cannot reach it: it decides from whether the *running* turn is audible to it, and a - // turn is audible to it the moment it hands over any content at all — a tool call, a - // line of reasoning — none of which is a word the transport ever spoke. So turn N+1 can - // be audible to the core while _spokenTurnId still names turn N, which is the turn the - // transport is still playing and the only one the caller has heard. Deciding here, where - // both ids are known and already under the lock that guards them, is what puts the heard - // text on turn N instead of on a turn nobody has heard one word of. - // - // Zero answers false as well. Nothing of this conversation has reached the transport, so there - // is no running turn the caller was hearing, and cutting one short would destroy a reply - // against nothing — the same rule the mark above already follows by staying put. - cutsRunningTurn = spoken != 0 && spoken == Interlocked.Read(ref _turnId); - } - - // The transport measured both values itself. D28 and item 6a: nothing here estimates either - // one. A false result means there was no turn to record the barge-in against — not merely - // that none was running, because a turn that already ended is amended rather than ignored. - bool recorded = session.Interrupt(heardText, playedDuration, cutsRunningTurn); - - // The transport already stopped its own audio, and anything the port still holds is the - // only audio left that could start it playing again. Handed to the observer rather than - // awaited: this runs on the read loop, and a read loop that blocks cannot receive the next - // frame. StopAsync itself is called here, before the discard, so whatever it does - // synchronously — dropping what has not been spoken — has already happened by the time this - // method returns. - _ = _observer.ObserveAsync(_output.StopAsync(_connectionToken).AsTask(), ConnectionTaskKind.WriteLoop); - - return recorded; - } - - /// Observes the turn that just ended, then starts the next one and follows it to its end. - /// The conversation the turn this starts runs against. - /// What the caller said. - /// The turn this conversation read out of . - /// A task that completes when the turn this conversation starts completes. - private async Task StartAfterAsync(IConversationPort session, string text, Task justFinished) - { - // A conversation runs many turns, and CurrentTurn only ever holds the most recent one. Observing the - // finished turn here is what stops a turn that faulted mid-conversation from being silently - // replaced by the next one before anything looks at it. The in-flight guard above only - // proved _turnActive was false, not that justFinished itself has completed: RunPendingPrompt - // clears _turnActive from inside RunTurnAsync's own finally, strictly before that turn's - // Task transitions to completed, so a moment can exist where _turnActive already reads false - // while justFinished is still finishing the last step of its own async state machine. This - // await can therefore still yield here, but only for as long as that tail takes to unwind — - // microseconds — which is why it is safe to await inline rather than fire-and-forget. Only - // the very last turn of the conversation still relies on teardown's own observation. - await _observer.ObserveAsync(justFinished, ConnectionTaskKind.Turn).ConfigureAwait(false); - - Task started; - lock (_turnLock) - { - // Teardown may have completed while the previous turn unwound; a turn started now - // would outlive the connection's own wait. - if (_connectionToken.IsCancellationRequested) - { - _turnActive = false; - return; - } - - long turnId = Interlocked.Increment(ref _turnId); - started = _turn = RunTurnAsync(session, text, turnId); - } - - try - { - await started.ConfigureAwait(false); - } - catch (Exception) - { - // Swallowed here on purpose, and only here. The task this method returns exists so a - // caller can follow the turn to its end; the fault itself stays on CurrentTurn, which - // the next turn's own observation above and teardown's both already report through - // ConnectionTaskObserver. Reporting it a second time here would log one fault twice. - } - } - - /// Runs one turn end to end, and streams its reply to the output port. - /// - /// The conversation this turn runs against. Passed in rather than read off the field, so a turn already - /// in flight when names another conversation still finishes on the one it started on. - /// - /// What the caller said. - /// The id this turn's output is marked with. - /// A task that completes when the turn has ended, however it ended. - private async Task RunTurnAsync(IConversationPort session, string text, long turnId) - { - try - { - await foreach (ChatResponseUpdate? update in session - .RunTurnStreamingAsync(text, _connectionToken) - .ConfigureAwait(false)) - { - // This check narrows the race; it does not close it. It sits after the await on the - // enumerator and before the await on the output port, so it stops every update the - // model produces *after* Interrupt marks this turn interrupted. It cannot stop - // an update already past this line at that moment — already queued inside the port, - // or parked inside SpeakAsync waiting for room — so the port's own last gate, - // immediately before the wire, is what actually keeps a straggler off the wire. - // That gate reads the port's own reply generation rather than this id, because no - // turn id of this class may cross the port; StopAsync raises the generation, and - // every fragment already queued under the old one is dropped there. The check is - // against _interruptedTurnId, not _turnId: a later, ordinary turn starting also - // moves _turnId, and that alone must not silence a turn nothing actually - // interrupted. - // - // continue, not return: abandoning the enumeration here would dispose - // ConversationSession's own iterator from the outside, which unwinds it past the code that - // records the interruption on LastTurn rather than through it. Session.Interrupt - // already asked the model call to stop; this loop still has to drain whatever - // update that call already had in flight so ConversationSession reaches its own ending on - // its own terms. Every update after the raised id is read and thrown away instead - // of queued, which is what keeps the queue itself from growing on a barge-in. - if (Interlocked.Read(ref _interruptedTurnId) == turnId) - { - continue; - } - - if (update.Text is { Length: > 0 } piece) - { - // Marked before the write, not after it. Once a piece is committed to the - // port the caller is going to hear it, and a barge-in that lands while this - // write waits on backpressure still belongs to this turn. The ids only ever - // increase here, because RunPendingPrompt starts the next turn from inside this - // turn's own finally, so a plain exchange cannot move this value backwards. - _ = Interlocked.Exchange(ref _spokenTurnId, turnId); - - await _output.SpeakAsync(piece, _connectionToken).ConfigureAwait(false); - } - } - - if (Interlocked.Read(ref _interruptedTurnId) != turnId) - { - if (session.LastTurn?.Approvals is { Count: > 0 }) - { - // A tool waits on a human answer, and a voice conversation has no surface to give one. - // Say so instead of hanging in the silence the pending turn speaks. - await _output.SpeakAsync(PendingApprovalNotice, _connectionToken).ConfigureAwait(false); - } - - // The vendor closes a reply on last: true, and the sample uses an empty final - // token when the stream ended with no trailing text. A barge-in already ended - // this turn, so a closing frame now would speak a reply the caller cut off. - await _output.CompleteAsync(_connectionToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - // ConversationSession absorbs a barge-in's own cancellation internally and ends the turn - // normally, so what reaches here is the connection's own teardown token instead. The - // conversation itself goes on; only this turn stops. - } - finally - { - RunPendingPrompt(); - } - } - - /// Starts the one prompt held while this turn ran. - /// - /// Called from a turn's own finally, so it runs on whichever thread that turn happens - /// to finish on — never the read loop, and strictly before the Task - /// returns actually completes. Reading , clearing or handing it to a new turn, and reassigning all - /// happen inside one lock acquisition, because a caller of that - /// reads _turnActive as false must also see as null in that - /// same instant — otherwise both this method and that caller could start a turn at once. - /// - private void RunPendingPrompt() - { - lock (_turnLock) - { - // The connection is tearing down, so a fresh turn here would speak after the transport - // stopped reading, and teardown's own read of CurrentTurn (also under this lock) may - // already have captured the task this conversation is inside of — leaving a turn nothing waits - // for. The caller's last sentence goes unanswered either way once the socket is gone. - if (_pendingPrompt is not { } held || _connectionToken.IsCancellationRequested) - { - _turnActive = false; - return; - } - - _pendingPrompt = null; - long turnId = Interlocked.Increment(ref _turnId); - - // Read here and not captured when the prompt was held: a setup frame that named another - // conversation in between makes that call the one to answer, which is the rule the connection - // followed when this method still lived there. - _turn = RunTurnAsync(_session, held, turnId); - } - } - } -} diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs index 4bb87029..fea7b305 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreBoot.cs @@ -12,6 +12,7 @@ using AgentCore.Application.Tools.Builtin; using AgentCore.Application.Tools.Registry; using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Voice; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -26,9 +27,11 @@ internal sealed class AgentCoreBoot : IAsyncDisposable, IDisposable private readonly AgentCoreOptions _options; private readonly ILoggerFactory _loggers; + private readonly List _opened = []; private readonly Lock _gate = new(); + private int _closed; /// Takes the options a host filled and the loggers the container holds. @@ -78,7 +81,7 @@ public AgentCoreBoot(IOptions options, ILoggerFactory loggers, /// Gets the queue that answers the audit port, not the store behind it. internal QueuedAuditSink AuditQueue => Started.AuditQueue; - /// Gets one factory, one agent, and one session store per entry. + /// Gets one factory and one agent per entry, over the one session owner shared by every entry. internal EntryRegistry Entries => Started.Entries; /// Gets the container, read for host-registered seams the boot honors. @@ -208,6 +211,7 @@ internal async ValueTask BootAsync(CancellationToken cancellationToken) .ConfigureAwait(false); ConfigurationValidator.ValidateToolReferences(configuration, tools.ServedIds); + FillerKeyCheck.Warn(configuration, tools.ServedIds, bootLogger); SkillCatalog? skills = await SkillsStartup .OpenAsync(_options, _loggers, cancellationToken) diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs index 72330457..93b4b347 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs @@ -4,6 +4,7 @@ using AgentCore.Application.Ports; using AgentCore.Application.Tools; using AgentCore.Application.Tools.Binding; +using AgentCore.AspNetCore.Voice; using Microsoft.Agents.AI; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; @@ -39,6 +40,9 @@ public sealed class AgentCoreOptions /// public HybridCache? Cache { get; set; } + /// Gets or sets how long a response id is kept before the retention sweep deletes it. + public TimeSpan? ResponseRetention { get; set; } = TimeSpan.FromDays(30); + /// Gets the map from a binds: name to the host delegate behind it. public ToolBindingRegistry Bindings { get; } = new(); @@ -102,18 +106,18 @@ public sealed class AgentCoreOptions /// Gets the observers the host registered, in the order it registered them. internal IReadOnlyList Observers => _observers; - /// Gets the store opener the host bound, or for the in-memory default. - internal Func? ConversationSessions { get; private set; } + /// Gets the owner opener the host bound, or for the in-memory default. + internal Func, IConversationSessions>? ConversationSessions { get; private set; } - /// Binds the session store, one per entry. + /// Binds the one session owner for the whole app, in place of the in-memory default. /// - /// Opens the store for one entry. It takes the entry name and the factory that builds that - /// entry's sessions, and it runs once per entry the document declares. Each conversation must return a - /// distinct store: two entries that share one store let a vendor call id arriving on both - /// entries read one call through two shapes. + /// Opens the owner. It takes every entry's session factory, keyed by entry name, and runs once for the + /// whole app. The owner's dictionary is keyed by conversation id alone, so one id is one conversation + /// across every entry: while one entry holds it live, an open from another entry is refused. /// /// These options, so a host chains its conversations. - public AgentCoreOptions UseConversationSessions(Func open) + public AgentCoreOptions UseConversationSessions( + Func, IConversationSessions> open) { ArgumentNullException.ThrowIfNull(open); ConversationSessions = open; @@ -335,7 +339,7 @@ public AgentCoreOptions AddToolSource(Func toolSo /// /// Binds the folder under which every conversation gets its own workspace directory, - /// <root>/<conversationId>/, created with the conversation and deleted when the conversation ends. + /// <root>/<conversationId>/, created with the conversation's session and deleted when the session unloads or the conversation ends. /// public AgentCoreOptions UseWorkspace(string root) { diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs index 956c4802..c493794d 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreServiceCollectionExtensions.cs @@ -11,8 +11,6 @@ using Microsoft.AspNetCore.WebSockets; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace AgentCore.AspNetCore.DependencyInjection @@ -35,6 +33,11 @@ public static IServiceCollection AddAgentCore( _ = services.AddOptions(); _ = services.Configure(configure); + _ = services.AddOptions() + .Validate( + static o => o.ResponseRetention is null || o.ResponseRetention >= TimeSpan.Zero, + "AgentCoreOptions.ResponseRetention must be null or zero or greater.") + .ValidateOnStart(); _ = services.AddLogging(); _ = services.AddSingleton(); @@ -62,6 +65,7 @@ public static IServiceCollection AddAgentCore( _ = services.AddSingleton(provider => provider.GetRequiredService()); + _ = services.AddHostedService(); _ = services.AddSingleton(Boot(boot => boot.Telemetry!)); _ = services.AddSingleton(Boot(boot => boot.Knowledge!)); @@ -77,12 +81,6 @@ public static IServiceCollection AddAgentCore( provider.GetRequiredService>().Value.Cache ?? PassThroughHybridCache.Instance); - _ = services.AddHostedService(provider => new ConversationSessionSweeper( - provider, - provider.GetRequiredService(), - provider.GetService()?.CreateLogger() - ?? NullLogger.Instance)); - services.TryAddSingleton(provider => new ChatConversationTitler( provider.GetRequiredService(), provider.GetRequiredService() @@ -99,30 +97,6 @@ public static IServiceCollection AddAgentCore( return services; } - /// - /// Opts this host into the periodic retention sweep of stored conversations. - /// - /// The service collection of the host. - /// Sets the retention, the interval, and the batch size. Defaults live on . - /// The same collection, so a host chains its conversations. - public static IServiceCollection AddConversationSweep( - this IServiceCollection services, - Action? configure = null) - { - ArgumentNullException.ThrowIfNull(services); - - OptionsBuilder builder = services.AddOptions(); - - if (configure is not null) - { - _ = builder.Configure(configure); - } - - _ = services.AddHostedService(); - - return services; - } - /// Registers WebSocket options that suit a phone conversation rather than a browser tab. /// The service collection of the host. /// The same collection, so a host chains its conversations. diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs index 3cdd393d..63ca2f3a 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSeamStartup.cs @@ -1,9 +1,7 @@ -using AgentCore.Application.Conversation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Ports; using AgentCore.Application.Providers; -using AgentCore.AspNetCore.Conversation; +using AgentCore.AspNetCore.Voice; using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.DependencyInjection.Startup diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs index 9930bb8b..4950ca7f 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/ConversationSessionStartup.cs @@ -10,7 +10,7 @@ namespace AgentCore.AspNetCore.DependencyInjection.Startup { /// Everything the conversation seam opened: the audit chain, and one session factory per entry. - /// One factory, one agent, and one session store per entry, keyed by entry name. + /// One factory and one agent per entry, and the one session owner shared by all of them. /// The queue that answers the audit port, not the store behind it. internal readonly record struct ConversationSessionSeam( EntryRegistry Entries, QueuedAuditSink Queue); @@ -18,7 +18,7 @@ internal readonly record struct ConversationSessionSeam( /// The seam a conversation arrives on: the audit queue, the observers, and one session factory per entry. internal static class ConversationSessionStartup { - /// Opens the audit store the document names and builds one session factory per entry over it. + /// Opens the audit store the document names, and builds the one session owner over every entry's factory. /// The owner the audit chain is tracked against. /// The loaded document. It carries providers.audit. /// The options the host filled. It carries the audit vendors, the clock, and any observer. @@ -47,7 +47,12 @@ internal static async ValueTask OpenAsync( StartupLog.AuditSinkDefaulted(loggers.CreateLogger()); } - QueuedAuditSink auditSink = boot.Track(new QueuedAuditSink(store, loggers.CreateLogger())); + TimeProvider timeProvider = options.TimeProvider ?? TimeProvider.System; + + QueuedAuditSink auditSink = boot.Track(new QueuedAuditSink( + store, + loggers.CreateLogger(), + timeProvider: timeProvider)); ILogger sessionLogger = loggers.CreateLogger(); @@ -68,15 +73,12 @@ internal static async ValueTask OpenAsync( } IReadOnlyList observers = ConversationObservers.Standard(auditSink, sessionLogger, options.Observers); - TimeProvider timeProvider = options.TimeProvider ?? TimeProvider.System; Dictionary factories = new(StringComparer.Ordinal); - Dictionary agents = new(StringComparer.Ordinal); - Dictionary sessions = new(StringComparer.Ordinal); foreach ((string? entryName, Application.Configuration.Compilation.CompiledAgent? compiled) in graph.Entries) { - ConversationSessionFactory factory = new( + factories[entryName] = new ConversationSessionFactory( compiled, graph.Guards, ConversationSessionFactory.CreateExtractor(compiled, graph.ChatClients), @@ -84,15 +86,40 @@ internal static async ValueTask OpenAsync( sessionLogger, observers, options.WorkspaceRoot); + } + + IConversationSessions sessions = options.ConversationSessions?.Invoke(factories) + ?? boot.Track(BuiltinSessions(factories, timeProvider, options.WorkspaceRoot, sessionLogger)); - factories[entryName] = factory; - agents[entryName] = new AgentCoreAgent(factory, entryName); - sessions[entryName] = options.ConversationSessions?.Invoke(entryName, factory) - ?? new InMemoryConversationSessions( - factory, InMemoryConversationSessions.DefaultIdleTimeout, timeProvider); + Dictionary agents = new(StringComparer.Ordinal); + foreach (string entryName in factories.Keys) + { + agents[entryName] = new AgentCoreAgent(sessions, entryName); } return new ConversationSessionSeam(new EntryRegistry(factories, agents, sessions), auditSink); } + + /// + /// Builds the default session owner, and — before anything can open a session through it — sweeps the + /// workspace root of every folder a crashed process left behind. The sweep runs here, once per boot, in + /// this one owner, and only for the built-in owner: a host that replaces it with its own implementation + /// through UseConversationSessions takes over its own workspace cleanup too. + /// + private static InMemoryConversationSessions BuiltinSessions( + IReadOnlyDictionary factories, + TimeProvider timeProvider, + string? workspaceRoot, + ILogger logger) + { + InMemoryConversationSessions sessions = new(factories, InMemoryConversationSessions.DefaultIdleTimeout, timeProvider); + + if (workspaceRoot is not null) + { + sessions.SweepWorkspaceRoot(workspaceRoot, logger); + } + + return sessions; + } } } diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs index 2e1869af..f1867629 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/EntryRegistry.cs @@ -3,33 +3,34 @@ namespace AgentCore.AspNetCore.DependencyInjection.Startup { - /// One factory, one agent shim, and one session store per entry. + /// One factory and one agent shim per entry, over the one session owner shared by every entry. /// - /// Each entry compiles its own shape over the shared pool, so each entry gets its own - /// session factory and its own store: a vendor call id arriving on two routes opens two - /// isolated conversations rather than one conversation read through two shapes. The audit chain, the - /// observers, and the workspace root are shared across entries, because they describe the - /// deployment rather than the shape. Agents are named by entry key. + /// Each entry compiles its own shape over the shared pool, so each entry gets its own session factory. + /// The session owner is shared, because it is the one dictionary, keyed by conversation id, that knows + /// which entry holds a conversation live: a vendor call id arriving on a second route is refused while + /// the first route still holds it. The audit chain, the observers, and the workspace root are shared + /// across entries too, because they describe the deployment rather than the shape. Agents are named by + /// entry key. /// internal sealed class EntryRegistry : IConversationSessionRegistry { - /// Creates the registry over the per-entry seams. + /// Creates the registry over the per-entry seams and the one session owner. /// The session factories, keyed by entry name. /// The agent shims, keyed by entry name. - /// The session stores, keyed by entry name. + /// The one session owner, shared by every entry. public EntryRegistry( IReadOnlyDictionary factories, IReadOnlyDictionary agents, - IReadOnlyDictionary conversationSessions) + IConversationSessions sessions) { ArgumentNullException.ThrowIfNull(factories); ArgumentNullException.ThrowIfNull(agents); - ArgumentNullException.ThrowIfNull(conversationSessions); + ArgumentNullException.ThrowIfNull(sessions); Factories = factories; Agents = agents; - ConversationSessions = conversationSessions; - Entries = [.. conversationSessions.Keys]; + Sessions = sessions; + Entries = [.. factories.Keys]; } /// Gets the session factories, keyed by entry name. @@ -38,8 +39,8 @@ public EntryRegistry( /// Gets the agent shims, keyed by entry name. public IReadOnlyDictionary Agents { get; } - /// Gets the session stores, keyed by entry name. - public IReadOnlyDictionary ConversationSessions { get; } + /// + public IConversationSessions Sessions { get; } /// public IReadOnlyCollection Entries { get; } @@ -62,12 +63,6 @@ public AgentCoreAgent ForAgent(string entry) return For(Agents, entry); } - /// - public IConversationSessions ForSessions(string entry) - { - return For(ConversationSessions, entry); - } - /// Builds the failure text for an entry nothing declares, in one place. /// The entry key a route named. /// The declared entry names. diff --git a/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs b/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs new file mode 100644 index 00000000..8eb30ff0 --- /dev/null +++ b/src/AgentCore.AspNetCore/DependencyInjection/Startup/FillerKeyCheck.cs @@ -0,0 +1,40 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.DependencyInjection.Startup +{ + /// + /// Warns about a providers.conversation.filler key that names no served tool. + /// A warning and not a refusal: a provider can add a tool of that name while a turn runs. + /// + internal static class FillerKeyCheck + { + /// Logs one warning for each filler key does not hold. + /// The loaded document. + /// Every tool id the registry serves, declared and MCP-discovered alike. + /// Where the warnings go. + internal static void Warn(AgentCoreConfiguration configuration, IReadOnlySet servedToolIds, ILogger logger) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(servedToolIds); + ArgumentNullException.ThrowIfNull(logger); + + if (configuration.Providers?.Conversation?.Filler is not { Count: > 0 } filler) + { + return; + } + + foreach (string toolId in filler.Keys.Order(StringComparer.Ordinal)) + { + if (!servedToolIds.Contains(toolId)) + { + StartupLog.FillerToolUnknown( + logger, + ConfigurationError.AppendPointer("/providers/conversation/filler", toolId), + toolId); + } + } + } + } +} diff --git a/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs b/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs index ce960bb5..50cc2655 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/StartupLog.cs @@ -27,5 +27,18 @@ internal static partial class StartupLog + "and no retention window applies to it. Name a durable providers.conversations.kind, or write " + "kind: memory to say this was meant.")] public static partial void ConversationStoreDefaulted(ILogger logger); + + /// A providers.conversation.filler key names no tool the registry serves. + /// The boot's own logger. + /// The JSON Pointer to the filler key. + /// The key, as the document wrote it. + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "{Pointer}: the filler key '{ToolId}' names no tool the registry serves, so its filler never " + + "fires unless a tool of that name is added at run time. The shell, files, memory, skills, and " + + "search providers name their tools only while a turn runs, so this check cannot see them. Check " + + "the key for a typo.")] + public static partial void FillerToolUnknown(ILogger logger, string pointer, string toolId); } } diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs index 8630522d..ebdc40a5 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesContracts.cs @@ -233,4 +233,14 @@ internal sealed record FilePayload /// Gets the link to fetch it from, or when the store has no web door. public string? Url { get; init; } } + + /// The ids one turn's commit wrote, as the browser reads it. + internal sealed record MessageCommittedPayload + { + /// Gets what the user's message was written under. + public required string UserMessageId { get; init; } + + /// Gets what the last message was written under, or when the user's is the only one. + public string? ReplyMessageId { get; init; } + } } diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs index 7bf03494..64444821 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesEndpoints.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using AgentCore.Application.Conversation; using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Harness; using AgentCore.AspNetCore.DependencyInjection; @@ -102,7 +103,7 @@ await ResponsesRequestReader.WriteErrorAsync( AgentCoreAgentSessionStore sessions = http.RequestServices.GetRequiredService(); - (AgentSession Session, string? ConversationId)? opened = await OpenSessionAsync(http, agent, sessions, run, approving: approval is not null, cancellationToken) + (AgentSession Session, string ConversationId)? opened = await OpenSessionAsync(http, agent, sessions, run, approving: approval is not null, cancellationToken) .ConfigureAwait(false); if (opened is null) @@ -110,18 +111,9 @@ await ResponsesRequestReader.WriteErrorAsync( return; } - (AgentSession? session, string? conversationId) = opened.Value; + (AgentSession session, string conversationId) = opened.Value; - if (session.GetService() is not { } conversation) - { - throw new InvalidOperationException( - "The session is not one this agent created, so it names no call to run."); - } - - ChatMessage? input = await ReadInputAsync(http, conversation, approval, run.Messages, cancellationToken) - .ConfigureAwait(false); - - if (input is null) + if (!await CheckInputAsync(http, approval, run.Messages, cancellationToken).ConfigureAwait(false)) { return; } @@ -129,17 +121,16 @@ await ResponsesRequestReader.WriteErrorAsync( // A conversation the request named stays the conversation; a keyless first turn minted // one alongside the session above. A response id is minted every turn either way, so a // chain can start from any answer. - ResponsesTurn turn = new( + ResponsesPendingTurn pending = new( agent, sessions, session, - conversation, - input, + approval is null ? ResponsesRequestReader.LastUserMessage(run.Messages) : null, + approval, ResponsesAgentCore.OriginOf(agentcore), - OpenAIResponses.CreateResponseId(), conversationId); - await RunTurnAsync(http, body, turn, dialect: agentcore is not null, cancellationToken).ConfigureAwait(false); + await RunTurnAsync(http, body, pending, dialect: agentcore is not null, cancellationToken).ConfigureAwait(false); } /// Opens the session the turn runs on: a new one, or the one the continuation names. @@ -147,7 +138,7 @@ await ResponsesRequestReader.WriteErrorAsync( /// The session and the conversation id it runs under, or once a /// refusal has been answered. /// - private static async Task<(AgentSession Session, string? ConversationId)?> OpenSessionAsync( + private static async Task<(AgentSession Session, string ConversationId)?> OpenSessionAsync( HttpContext http, AgentCoreAgent agent, AgentCoreAgentSessionStore sessions, @@ -158,8 +149,10 @@ await ResponsesRequestReader.WriteErrorAsync( string? key = run.ContinuationKey; string? conversationId = run.ConversationId; - bool known = key is not null - && await sessions.ContainsAsync(key, cancellationToken).ConfigureAwait(false); + string? resolved = key is null + ? null + : await sessions.ResolveConversationIdAsync(key, cancellationToken).ConfigureAwait(false); + bool known = resolved is not null; bool namesNewConversation = key is not null && !known @@ -167,54 +160,57 @@ await ResponsesRequestReader.WriteErrorAsync( && conversationId is not null && !approving; - if (key is null) + try { - if (approving) + if (key is null) + { + if (approving) + { + await ResponsesRequestReader.WriteErrorAsync( + http, + StatusCodes.Status400BadRequest, + "an approval answer names the conversation it resumes. Send it with the conversation " + + "or previous response id the asking turn answered with.", + "missing_session", + cancellationToken).ConfigureAwait(false); + return null; + } + + conversationId = ResponsesRequestReader.NewConversationId(); + return (await agent.CreateSessionAsync(conversationId, cancellationToken).ConfigureAwait(false), conversationId); + } + + if (namesNewConversation) + { + return (await agent.CreateSessionAsync(key, cancellationToken).ConfigureAwait(false), conversationId!); + } + + if (!known) { await ResponsesRequestReader.WriteErrorAsync( http, - StatusCodes.Status400BadRequest, - "an approval answer names the conversation it resumes. Send it with the conversation " - + "or previous response id the asking turn answered with.", - "missing_session", + StatusCodes.Status404NotFound, + $"no conversation opens under '{key}'. Send the request with neither a conversation nor a " + + "previous response id to start one.", + "continuation_not_found", cancellationToken).ConfigureAwait(false); return null; } - conversationId = ResponsesRequestReader.NewConversationId(); - return (await agent.CreateSessionAsync(conversationId).ConfigureAwait(false), conversationId); - } - - if (namesNewConversation) - { - // Unknown conversation with words: start the conversation under that id, so the conversation - // and the continuation key are one id. Unknown response ids stay 404 below: they name - // a turn, not a conversation. - return (await agent.CreateSessionAsync(key).ConfigureAwait(false), conversationId); + return (await sessions.GetSessionForConversationAsync(agent, resolved!, cancellationToken).ConfigureAwait(false), resolved!); } - - if (!known) + catch (ConversationInUseException failure) { - // Either an unknown response id — a turn no answer ever carried — or an unknown - // conversation beside an approval-only body the parse refused. A conversation with words - // reaches the namesNewConversation branch; an approval never starts a conversation. await ResponsesRequestReader.WriteErrorAsync( http, - StatusCodes.Status404NotFound, - $"no conversation opens under '{key}'. Send the request with neither a conversation nor a " - + "previous response id to start one.", - "continuation_not_found", + StatusCodes.Status409Conflict, + failure.Message, + "conversation_in_use", cancellationToken).ConfigureAwait(false); return null; } - - try - { - return (await sessions.GetSessionAsync(agent, key, cancellationToken).ConfigureAwait(false), conversationId); - } catch (InvalidOperationException failure) { - // A key minted by another entry is unknown to this one, never a cross-entry read. await ResponsesRequestReader.WriteErrorAsync( http, StatusCodes.Status404NotFound, @@ -225,32 +221,26 @@ await ResponsesRequestReader.WriteErrorAsync( } } - /// Picks what the turn runs on: the approval answer, or the last user message with words. - /// The input, or once a refusal has been answered. - private static async Task ReadInputAsync( + /// Checks the request carries one thing to run on: user words, or an approval answer without words. + /// once a refusal has been answered. + private static async Task CheckInputAsync( HttpContext http, - ConversationSession conversation, ResponsesApprovalAnswer? approval, IReadOnlyList messages, CancellationToken cancellationToken) { - if (approval is null) + if (approval is null && ResponsesRequestReader.LastUserMessage(messages) is null) { - if (ResponsesRequestReader.LastUserMessage(messages) is { } user) - { - return user; - } - await ResponsesRequestReader.WriteErrorAsync( http, StatusCodes.Status400BadRequest, "the request carries no user message with text, so there is no turn to run.", "no_user_message", cancellationToken).ConfigureAwait(false); - return null; + return false; } - if (ResponsesRequestReader.LastUserText(messages) is { Length: > 0 }) + if (approval is not null && ResponsesRequestReader.LastUserText(messages) is { Length: > 0 }) { await ResponsesRequestReader.WriteErrorAsync( http, @@ -259,9 +249,23 @@ await ResponsesRequestReader.WriteErrorAsync( + "either words or an answer, never both.", "mixed_turn", cancellationToken).ConfigureAwait(false); - return null; + return false; } + return true; + } + + /// + /// Reads the approval answer off the conversation's queue, once the request holds the conversation, so no other + /// turn changes the queue between this read and the turn. + /// + /// The answer, or once a refusal has been answered. + private static async Task ReadApprovalAsync( + HttpContext http, + ConversationSession conversation, + ResponsesApprovalAnswer approval, + CancellationToken cancellationToken) + { // Async: a resumed conversation has run no turn in this process, so its ledger session — // and the queue the suspending turn filed — is still closed. This opens it first. if (await conversation.TryCreateApprovalAnswerAsync(approval.RequestId, approval.Approved, cancellationToken).ConfigureAwait(false) is { } answer) @@ -279,39 +283,87 @@ await ResponsesRequestReader.WriteErrorAsync( return null; } - /// Runs the turn on the branch the body asked for, and answers a refused turn as 409. + /// + /// Runs the turn on the branch the body asked for, once the conversation is free, and answers a refused turn as + /// 409. + /// private static async Task RunTurnAsync( HttpContext http, JsonElement body, - ResponsesTurn turn, + ResponsesPendingTurn pending, bool dialect, CancellationToken cancellationToken) { bool streaming = body.TryGetProperty("stream", out JsonElement streamFlag) && streamFlag.ValueKind == JsonValueKind.True; - if (CallerTimeZone.Parse(http.Request.Headers[TimeZoneHeaderName]) is { } zone) - { - CallerTimeZone.Set(turn.Session, zone); - } - + string refusedReason = TurnRefusals.Busy; try { - if (streaming) + // The conversation stays busy until the session is filed, after the abort too: a message sent at once, + // to this host or another, then waits for this turn's words instead of racing them to the same turn index. + ConversationSession conversation = await pending.Agent + .EnterRequestAsync(pending.Session, cancellationToken).ConfigureAwait(false); + refusedReason = TurnRefusals.Conflict; + try { - // The dialect is opt-in by the member only our clients send: an OpenAI SDK - // never carries agentcore, so its stream stays the framework's pure shapes. - await ResponsesTurnStream.WriteAsync(http, turn, dialect, cancellationToken).ConfigureAwait(false); + ChatMessage? input = pending.Approval is { } approval + ? await ReadApprovalAsync(http, conversation, approval, cancellationToken).ConfigureAwait(false) + : pending.Words; + + if (input is null) + { + return; + } + + if (CallerTimeZone.Parse(http.Request.Headers[TimeZoneHeaderName]) is { } zone) + { + CallerTimeZone.Set(pending.Session, zone); + } + + ResponsesTurn turn = new( + pending.Agent, + pending.Sessions, + pending.Session, + conversation, + input, + pending.Origin, + OpenAIResponses.CreateResponseId(), + pending.ConversationId); + + if (streaming) + { + // The dialect is opt-in by the member only our clients send: an OpenAI SDK + // never carries agentcore, so its stream stays the framework's pure shapes. + await ResponsesTurnStream.WriteAsync(http, turn, dialect, cancellationToken).ConfigureAwait(false); + } + else + { + await ResponsesTurnReply.WriteAsync(http, turn, cancellationToken).ConfigureAwait(false); + } } - else + finally { - await ResponsesTurnReply.WriteAsync(http, turn, cancellationToken).ConfigureAwait(false); + await conversation.Busy.ExitRequestAsync().ConfigureAwait(false); } } + catch (ConversationTurnConflictException) when (!http.Response.HasStarted) + { + await ResponsesRequestReader.WriteErrorAsync( + http, + StatusCodes.Status409Conflict, + ResponsesTurnConflict.MessageFor(refusedReason), + ResponsesTurnConflict.Code, + cancellationToken).ConfigureAwait(false); + } + catch (ConversationTurnConflictException) + { + // The stream already ended with its error event, or the client that could read one is gone. + } catch (InvalidOperationException exception) { - // The turn loop refuses a turn on a finished conversation, and refuses a second turn while one - // runs. Both are a caller mistake and neither is a defect of this host. + // The turn loop refuses a turn on a finished conversation, or on one disposed under it. Neither is a + // defect of this host. await ResponsesRequestReader.WriteErrorAsync( http, StatusCodes.Status409Conflict, diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs new file mode 100644 index 00000000..85e8f893 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesPendingTurn.cs @@ -0,0 +1,24 @@ +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Sessions; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Endpoints +{ + /// What a Responses request asks to run, before it holds the conversation. + /// The entry the URL named. + /// The store the session is filed in after the turn. + /// The session the turn advances. + /// The user words the turn runs on, or for an approval answer. + /// The approval answer the turn runs on, or for words. + /// Where the request said the turn came from, or . + /// The id of the conversation the turn runs on. + internal sealed record ResponsesPendingTurn( + AgentCoreAgent Agent, + AgentCoreAgentSessionStore Sessions, + AgentSession Session, + ChatMessage? Words, + ResponsesApprovalAnswer? Approval, + ConversationTurnOrigin? Origin, + string ConversationId); +} diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs index 522329e6..cbfd3d31 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesSessionFiling.cs @@ -1,38 +1,22 @@ using AgentCore.AspNetCore.Sessions; -using Microsoft.Agents.AI; namespace AgentCore.AspNetCore.Endpoints { - /// Files a session the Responses path just advanced, under every id that names it. + /// Files a response id the Responses path just minted, so it later resolves to its conversation. internal static class ResponsesSessionFiling { - /// - /// Files the session the turn just advanced, under every id that names it now. The response id - /// always gets the frozen, full envelope; the conversation id gets a pointer at it, so the two - /// never hold two copies of the same state. - /// - /// The store to file the envelope in. - /// The agent the session belongs to. - /// The session the turn advanced. + /// Records that one response id continues one conversation. + /// The store to file the response id in. /// The id minted for this turn's answer. - /// The client-facing conversation id, or when none names it. - /// The internal conversation the continuation rows belong to, always present. + /// The conversation the response id continues. /// Cancels the write. internal static async Task SaveAsync( AgentCoreAgentSessionStore sessions, - AIAgent agent, - AgentSession session, string responseId, - string? conversationId, string ownerConversationId, CancellationToken cancellationToken) { - await sessions.SaveSessionForConversationAsync(agent, responseId, ownerConversationId, session, cancellationToken).ConfigureAwait(false); - - if (conversationId is not null) - { - await sessions.SavePointerAsync(conversationId, ownerConversationId, responseId, cancellationToken).ConfigureAwait(false); - } + await sessions.SaveContinuationAsync(responseId, ownerConversationId, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs index 4d8e8735..d404c13f 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurn.cs @@ -5,15 +5,15 @@ namespace AgentCore.AspNetCore.Endpoints { - /// One turn the Responses path is about to run, and every id the answer files under. + /// One turn the Responses path is about to run, and the response id the answer files under. /// The entry the URL named. - /// The store the session is filed in after the turn. + /// The store the response id is filed in after the turn. /// The session the turn advances. /// The conversation inside . /// The user words, or the approval answer, the turn runs on. /// Where the request said the turn came from, or . /// The id minted for this turn's answer. - /// The id of the conversation, or when none names it. + /// The id of the conversation the answer reports, which is 's own id whenever the request resumed it directly, and the resolved owner when it resumed through a response id. internal sealed record ResponsesTurn( AgentCoreAgent Agent, AgentCoreAgentSessionStore Sessions, @@ -22,13 +22,19 @@ internal sealed record ResponsesTurn( ChatMessage Input, ConversationTurnOrigin? Origin, string ResponseId, - string? ConversationId) + string ConversationId) { - /// Files the session, under every id that names it now. + /// + /// Records that continues this conversation, unless the turn's own store write + /// was refused: the response id already names the session that saved that turn, and filing this one + /// would misname it. A turn whose write merely failed is still recorded here: nothing else holds the + /// response id, and store 0's own state outranks it on any later resume. + /// public Task FileAsync(CancellationToken cancellationToken) { - return ResponsesSessionFiling.SaveAsync( - Sessions, Agent, Session, ResponseId, ConversationId, Conversation.ConversationId, cancellationToken); + return Conversation.Ledger.Reads.Refused + ? Task.CompletedTask + : ResponsesSessionFiling.SaveAsync(Sessions, ResponseId, Conversation.ConversationId, cancellationToken); } } } diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs new file mode 100644 index 00000000..d23d1594 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnConflict.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.Application.Runtime; +using Microsoft.AspNetCore.Http; + +namespace AgentCore.AspNetCore.Endpoints +{ + /// + /// How the Responses path answers a turn it refused: another request held the conversation past the wait limit, or + /// saved the same turn of the conversation first. + /// + internal static class ResponsesTurnConflict + { + /// The error code of the answer, streamed or not. + public const string Code = "turn_conflict"; + + /// What the caller reads when another request saved the same turn first, after this turn ran. + public const string ConflictMessage = + "another request saved this turn of the conversation first, so this turn's reply was not saved. Reload the " + + "conversation and send the message again."; + + /// What the caller reads when another request still held the conversation, so this turn never ran. + public const string BusyMessage = + "another request is still running a turn of this conversation, so this turn was refused before it ran. Wait " + + "for that turn to finish and send the message again."; + + /// Picks the message for a reason. + /// The refusedReason of the turn's turn.refused event. + /// The message the caller reads. + public static string MessageFor(string refusedReason) => + refusedReason == TurnRefusals.Busy ? BusyMessage : ConflictMessage; + + /// + /// Ends a stream whose headers already left with the Responses error event, in place of the + /// response.completed the turn never reached. + /// + /// The request being answered. + /// The last frame the stream wrote, or when it wrote none. + /// Cancels the write. + public static async Task WriteEventAsync(HttpContext http, string? lastFrame, CancellationToken cancellationToken) + { + JsonObject error = new() + { + ["type"] = "error", + ["code"] = Code, + ["message"] = ConflictMessage, + ["param"] = null, + ["sequence_number"] = NextSequence(lastFrame), + }; + + await http.Response.WriteAsync( + string.Create(CultureInfo.InvariantCulture, $"event: error\ndata: {error.ToJsonString()}\n\n"), + cancellationToken) + .ConfigureAwait(false); + await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + /// Reads the sequence number that follows the last frame's, or 0 when there was none. + private static int NextSequence(string? lastFrame) + { + int data = lastFrame?.IndexOf("data: ", StringComparison.Ordinal) ?? -1; + if (data < 0) + { + return 0; + } + + using JsonDocument frame = JsonDocument.Parse(lastFrame![(data + "data: ".Length)..]); + return frame.RootElement.TryGetProperty("sequence_number", out JsonElement sequence) + && sequence.TryGetInt32(out int number) + ? number + 1 + : 0; + } + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnLog.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnLog.cs new file mode 100644 index 00000000..29ea16c8 --- /dev/null +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnLog.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Endpoints +{ + /// The lines the Responses path writes while it runs a turn. + /// Source-generated by , as CA1848 asks. + internal static partial class ResponsesTurnLog + { + /// The client aborted the stream, and filing the session outran its bound. + /// The logger of the Responses path. + /// The conversation whose session was not filed. + /// How long the filing was given. + [LoggerMessage( + EventId = 1, + Level = LogLevel.Warning, + Message = "the client aborted the stream of conversation '{ConversationId}', and filing its session " + + "took longer than {Bound}, so it was abandoned. A continuation of this turn may not resume.")] + public static partial void FilingAfterAbortTimedOut(ILogger logger, string conversationId, TimeSpan bound); + } +} diff --git a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs index 7da5408d..94c06b92 100644 --- a/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs +++ b/src/AgentCore.AspNetCore/Endpoints/ResponsesTurnStream.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using AgentCore.Application.Conversation; +using AgentCore.Application.Runtime; using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tools; using Microsoft.Agents.AI; @@ -8,6 +9,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace AgentCore.AspNetCore.Endpoints { @@ -24,47 +27,109 @@ internal static async Task WriteAsync( bool dialect, CancellationToken cancellationToken) { + // Started before the headers: only a started turn has opened its stored state and caught up on + // other sessions', so only then does the stage name the one the turn speaks in. + await using TurnRun run = await turn.Conversation + .StartTurnAsync(turn.Input, turn.Origin, cancellationToken).ConfigureAwait(false); + http.Response.StatusCode = StatusCodes.Status200OK; http.Response.ContentType = "text/event-stream"; http.Response.Headers.CacheControl = "no-cache"; - - // The headers leave before the turn ends, so this one names the stage the turn speaks in. http.Response.Headers[ResponsesEndpointRouteBuilderExtensions.StageHeaderName] = turn.Conversation.Stage; // One turn's worth of ids: the pairing dies with the stream. ToolCallNames toolNames = new(); - IAsyncEnumerable updates = StreamAgentUpdatesAsync(http, turn, dialect, toolNames, cancellationToken); + IAsyncEnumerable updates = StreamAgentUpdatesAsync(http, turn, run, dialect, toolNames, cancellationToken); + string? lastFrame = null; - await foreach (string? frame in OpenAIResponses - .WriteResponseStreamAsync(updates, turn.ResponseId, turn.ConversationId, cancellationToken) - .ConfigureAwait(false)) + try { - await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + await foreach (string? frame in OpenAIResponses + .WriteResponseStreamAsync(updates, turn.ResponseId, turn.ConversationId, cancellationToken) + .ConfigureAwait(false)) + { + await http.Response.WriteAsync(frame, cancellationToken).ConfigureAwait(false); - // Without this the reply arrives in one piece, which defeats the whole streaming path. - await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); + // Without this the reply arrives in one piece, which defeats the whole streaming path. + await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); + lastFrame = frame; + } + } + catch (ConversationTurnConflictException) when (!cancellationToken.IsCancellationRequested) + { + // The reply already streamed, so the refusal can only end the stream, never set its status. + await ResponsesTurnConflict.WriteEventAsync(http, lastFrame, cancellationToken).ConfigureAwait(false); + } + finally + { + // The turn commits its words even when the host cancels mid-stream (design section 3), so the + // session still needs filing here, on a token that outlives the abort that cancelled the one above. + if (cancellationToken.IsCancellationRequested) + { + await FileAfterAbortAsync( + turn.FileAsync, + turn.Conversation.ConversationId, + turn.Conversation.Time, + http.RequestServices.GetService()?.CreateLogger(typeof(ResponsesTurnStream)) + ?? NullLogger.Instance) + .ConfigureAwait(false); + } + else + { + await turn.FileAsync(cancellationToken).ConfigureAwait(false); + } } + } + + /// + /// Files the session of a turn whose client has gone, within + /// : the same bound the rest of the work after + /// the reply runs under once the host's token no longer counts. + /// + /// Files the session; it reads the bound's token. + /// The conversation being filed, for the log. + /// The clock the bound runs on. + /// Where a filing that outruns the bound is reported. + /// + /// The wait ends at the bound even when ignores its token. A filing that + /// outruns it is logged and dropped, not thrown: the client that could read the error is gone. + /// + internal static async Task FileAfterAbortAsync( + Func file, string conversationId, TimeProvider time, ILogger logger) + { + TimeSpan bound = ConversationSession.TurnCompletionTimeout; + using CancellationTokenSource deadline = new(bound, time); - await turn.FileAsync(cancellationToken).ConfigureAwait(false); + try + { + await file(deadline.Token).WaitAsync(deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + ResponsesTurnLog.FilingAfterAbortTimedOut(logger, conversationId, bound); + } } - /// Runs one streaming turn on the conversation, wrapping each update with the agent's id. + /// Reads one started turn's reply, wrapping each update with the agent's id. private static async IAsyncEnumerable StreamAgentUpdatesAsync( HttpContext http, ResponsesTurn turn, + TurnRun run, bool dialect, ToolCallNames toolNames, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { TurnStreamFiles files = new(); - await foreach (ChatResponseUpdate? update in turn.Conversation - .RunTurnMessageStreamingAtOriginAsync(turn.Input, turn.Origin, cancellationToken) - .ConfigureAwait(false)) + await foreach (ChatResponseUpdate? update in run.Updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { bool isNotice = update.Contents.OfType().Any(); + // Carries no text of its own (E5): the caller reads it off the agentcore_message_committed + // dialect part instead, never as spoken or output text. + bool isCommitted = update.Contents.OfType().Any(); + if (dialect) { foreach (TurnStreamPart part in TurnStreamParts.From(update, toolNames)) @@ -72,13 +137,13 @@ private static async IAsyncEnumerable StreamAgentUpdatesAsy await WritePartLineAsync(http, part, cancellationToken).ConfigureAwait(false); } - if (!isNotice) + if (!isNotice && !isCommitted) { files.Note(update); } } - if (isNotice) + if (isNotice || isCommitted) { continue; } diff --git a/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs b/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs index 7d80cd58..0804f1bc 100644 --- a/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs +++ b/src/AgentCore.AspNetCore/Endpoints/TurnStreamParts.cs @@ -58,6 +58,15 @@ internal static IEnumerable From(ChatResponseUpdate update, Tool yield return part; } } + + foreach (TurnCommittedContent committed in update.Contents.OfType()) + { + yield return new TurnStreamPart(TurnStreamPart.MessageCommitted, new MessageCommittedPayload + { + UserMessageId = committed.UserMessageId, + ReplyMessageId = committed.ReplyMessageId, + }); + } } /// Frames one notice for the browser, or for a kind it does not show. @@ -163,5 +172,8 @@ internal sealed record TurnStreamPart(string Member, object Payload) /// One file the turn produced: a . public const string File = "agentcore_file"; + + /// The ids one turn's commit wrote: a . + public const string MessageCommitted = "agentcore_message_committed"; } } diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt index f34457dc..6025d79d 100644 --- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt +++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt @@ -1,7 +1,7 @@ #nullable enable -AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions -AgentCore.AspNetCore.Conversation.IConversationTransportAdapter -AgentCore.AspNetCore.Conversation.IConversationTransportAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate! +AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions +AgentCore.AspNetCore.Voice.IConversationTransportAdapter +AgentCore.AspNetCore.Voice.IConversationTransportAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate! AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.AgentCoreHttpClients(System.Net.Http.HttpMessageHandler? primaryHandler = null, Microsoft.Extensions.Logging.ILoggerFactory? loggers = null) -> void AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.CreateClient(string! name) -> System.Net.Http.HttpClient! @@ -25,9 +25,11 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.TimeProvider.get -> Sy AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.TimeProvider.set -> void AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.Cache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache? AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.Cache.set -> void +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.ResponseRetention.get -> System.TimeSpan? +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.ResponseRetention.set -> void AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseAuditSinks(params AgentCore.Application.Ports.IAuditSinkAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! -AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversation(params AgentCore.Application.Ports.IConversationAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! -AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationSessions(System.Func! open) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversation(params AgentCore.AspNetCore.Voice.IConversationAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationSessions(System.Func!, AgentCore.Application.Ports.IConversationSessions!>! open) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationStores(params AgentCore.Application.Ports.IConversationStoreAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func>! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! @@ -43,7 +45,7 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseModeration(params A AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseObservers(params AgentCore.Application.Ports.IConversationObserver![]! observers) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSkills(Microsoft.Agents.AI.AgentSkillsSource! source) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSkills(string! path) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! -AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSpeech(params AgentCore.Application.Ports.ISpeechAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseSpeech(params AgentCore.AspNetCore.Voice.ISpeechAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseWorkspace(string! root) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseTelemetry(params AgentCore.Application.Ports.ITelemetryAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions @@ -54,11 +56,12 @@ AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.AgentCoreAgentSessionStore(AgentCore.Application.Ports.IConversationStore! conversations) -> void AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.ContainsAsync(string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.ResolveConversationIdAsync(string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.GetSessionForConversationAsync(Microsoft.Agents.AI.AIAgent! agent, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SaveContinuationAsync(string! responseId, string! conversationId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.DeleteSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask override AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SaveSessionForConversationAsync(Microsoft.Agents.AI.AIAgent! agent, string! sessionStoreId, string! conversationId, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -AgentCore.AspNetCore.Sessions.AgentCoreAgentSessionStore.SavePointerAsync(string! sessionStoreId, string! conversationId, string! target, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.CarriesText.get -> bool AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.CreateHandler(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! configuration) -> Microsoft.AspNetCore.Http.RequestDelegate! @@ -67,8 +70,8 @@ AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRe AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.Kind.get -> string! AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.TelnyxRelaySpeechAdapter() -> void -const AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/call" -> string! -const AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter = "entry" -> string! +const AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/call" -> string! +const AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter = "entry" -> string! const AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.MaxAttempts = 3 -> int const AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.PipelineName = "agentcore-http" -> string! const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.DefaultPattern = "/v1/{entry}/responses" -> string! @@ -76,9 +79,9 @@ const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.Ent const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.StageHeaderName = "X-AgentCore-Stage" -> string! const AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelayConversationAdapter.TelnyxRelayKind = "telnyx-relay" -> string! const AgentCore.AspNetCore.Vendors.TelnyxRelay.TelnyxRelaySpeechAdapter.TelnyxRelayKind = "telnyx-relay" -> string! -static AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.EntryOf(Microsoft.AspNetCore.Http.HttpContext! http) -> string! -static AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! -static AgentCore.AspNetCore.Conversation.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +static AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryOf(Microsoft.AspNetCore.Http.HttpContext! http) -> string! +static AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! +static AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.MapCall(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddAgentCore(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddAgentCoreWebSockets(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.MapResponses(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! @@ -88,14 +91,117 @@ static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.Fi static readonly AgentCore.AspNetCore.DependencyInjection.AgentCoreHttpClients.RequestDeadline -> System.TimeSpan AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseBlobStores(params AgentCore.Application.Ports.IBlobStoreAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! const AgentCore.AspNetCore.Endpoints.ResponsesEndpointRouteBuilderExtensions.TimeZoneHeaderName = "X-AgentCore-Time-Zone" -> string! -AgentCore.AspNetCore.Conversation.ConversationSweepOptions -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.ConversationSweepOptions() -> void -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Retention.get -> System.TimeSpan -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Retention.set -> void -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Interval.get -> System.TimeSpan -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.Interval.set -> void -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.BatchSize.get -> int -AgentCore.AspNetCore.Conversation.ConversationSweepOptions.BatchSize.set -> void AgentCore.AspNetCore.Conversation.ConversationSweeper -AgentCore.AspNetCore.Conversation.ConversationSweeper.ConversationSweeper(System.IServiceProvider! services, Microsoft.Extensions.Options.IOptions! options, System.TimeProvider! timeProvider, Microsoft.Extensions.Logging.ILogger! logger) -> void -static AgentCore.AspNetCore.DependencyInjection.AgentCoreServiceCollectionExtensions.AddConversationSweep(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configure = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +AgentCore.AspNetCore.Conversation.ConversationSweeper.ConversationSweeper(System.IServiceProvider! services, Microsoft.Extensions.Options.IOptions! options, System.TimeProvider! timeProvider, Microsoft.Extensions.Logging.ILogger! logger) -> void +AgentCore.AspNetCore.Voice.ConversationChannel +AgentCore.AspNetCore.Voice.ConversationChannel.$() -> AgentCore.AspNetCore.Voice.ConversationChannel! +AgentCore.AspNetCore.Voice.ConversationChannel.ConversationChannel(AgentCore.AspNetCore.Voice.IConversationInputPort! Input, AgentCore.AspNetCore.Voice.IConversationOutputPort! Output) -> void +AgentCore.AspNetCore.Voice.ConversationChannel.Deconstruct(out AgentCore.AspNetCore.Voice.IConversationInputPort! Input, out AgentCore.AspNetCore.Voice.IConversationOutputPort! Output) -> void +AgentCore.AspNetCore.Voice.ConversationChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.ConversationChannel.Equals(AgentCore.AspNetCore.Voice.ConversationChannel? other) -> bool +AgentCore.AspNetCore.Voice.ConversationChannel.Input.get -> AgentCore.AspNetCore.Voice.IConversationInputPort! +AgentCore.AspNetCore.Voice.ConversationChannel.Input.init -> void +AgentCore.AspNetCore.Voice.ConversationChannel.Output.get -> AgentCore.AspNetCore.Voice.IConversationOutputPort! +AgentCore.AspNetCore.Voice.ConversationChannel.Output.init -> void +AgentCore.AspNetCore.Voice.ConversationChannelContext +AgentCore.AspNetCore.Voice.ConversationChannelContext.$() -> AgentCore.AspNetCore.Voice.ConversationChannelContext! +AgentCore.AspNetCore.Voice.ConversationChannelContext.ConversationChannelContext(string! ConversationId, System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void +AgentCore.AspNetCore.Voice.ConversationChannelContext.ConversationId.get -> string! +AgentCore.AspNetCore.Voice.ConversationChannelContext.ConversationId.init -> void +AgentCore.AspNetCore.Voice.ConversationChannelContext.CustomParameters.get -> System.Collections.Generic.IReadOnlyDictionary? +AgentCore.AspNetCore.Voice.ConversationChannelContext.CustomParameters.init -> void +AgentCore.AspNetCore.Voice.ConversationChannelContext.Deconstruct(out string! ConversationId, out System.Collections.Generic.IReadOnlyDictionary? CustomParameters) -> void +AgentCore.AspNetCore.Voice.ConversationChannelContext.Equals(AgentCore.AspNetCore.Voice.ConversationChannelContext? other) -> bool +AgentCore.AspNetCore.Voice.ConversationInput +AgentCore.AspNetCore.Voice.ConversationInput.Barge +AgentCore.AspNetCore.Voice.ConversationInput.Barge.Barge(string! HeardText, System.TimeSpan PlayedDuration) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Barge.Deconstruct(out string! HeardText, out System.TimeSpan PlayedDuration) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Barge? other) -> bool +AgentCore.AspNetCore.Voice.ConversationInput.Barge.HeardText.get -> string! +AgentCore.AspNetCore.Voice.ConversationInput.Barge.HeardText.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.Barge.PlayedDuration.get -> System.TimeSpan +AgentCore.AspNetCore.Voice.ConversationInput.Barge.PlayedDuration.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.ConversationInput(AgentCore.AspNetCore.Voice.ConversationInput! original) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Keypress +AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Deconstruct(out string! Key) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Keypress? other) -> bool +AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Key.get -> string! +AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Key.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Keypress(string! Key) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Utterance +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Deconstruct(out string! Text, out string! Language, out bool IsFinal) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Utterance? other) -> bool +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.IsFinal.get -> bool +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.IsFinal.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Language.get -> string! +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Language.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Text.get -> string! +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Text.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Utterance(string! Text, string! Language, bool IsFinal) -> void +AgentCore.AspNetCore.Voice.ConversationSpeechPairing +AgentCore.AspNetCore.Voice.IConversationAdapter +AgentCore.AspNetCore.Voice.IConversationAdapter.CarriesText.get -> bool +AgentCore.AspNetCore.Voice.IConversationChannelFactory +AgentCore.AspNetCore.Voice.IConversationChannelFactory.OpenAsync(AgentCore.AspNetCore.Voice.ConversationChannelContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.IConversationInputPort +AgentCore.AspNetCore.Voice.IConversationInputPort.ListenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +AgentCore.AspNetCore.Voice.IConversationOutputPort +AgentCore.AspNetCore.Voice.IConversationOutputPort.BeginReply() -> void +AgentCore.AspNetCore.Voice.IConversationOutputPort.CompleteAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.IConversationOutputPort.SpeakAsync(string! fragment, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.IConversationOutputPort.StopAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.AspNetCore.Voice.ISpeechAdapter +AgentCore.AspNetCore.Voice.ConversationInput.Started +AgentCore.AspNetCore.Voice.ConversationInput.Started.Deconstruct(out string! ConversationId) -> void +AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.ConversationInput.Started? other) -> bool +AgentCore.AspNetCore.Voice.ConversationInput.Started.ConversationId.get -> string! +AgentCore.AspNetCore.Voice.ConversationInput.Started.ConversationId.init -> void +AgentCore.AspNetCore.Voice.ConversationInput.Started.Started(string! ConversationId) -> void +abstract AgentCore.AspNetCore.Voice.ConversationInput.$() -> AgentCore.AspNetCore.Voice.ConversationInput! +override AgentCore.AspNetCore.Voice.ConversationChannel.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationChannel.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationChannel.ToString() -> string! +override AgentCore.AspNetCore.Voice.ConversationChannelContext.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationChannelContext.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationChannelContext.ToString() -> string! +override AgentCore.AspNetCore.Voice.ConversationInput.Barge.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Barge! +override AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationInput.Barge.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationInput.Barge.ToString() -> string! +override AgentCore.AspNetCore.Voice.ConversationInput.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationInput.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Keypress! +override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationInput.Keypress.ToString() -> string! +override AgentCore.AspNetCore.Voice.ConversationInput.ToString() -> string! +override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Utterance! +override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationInput.Utterance.ToString() -> string! +static AgentCore.AspNetCore.Voice.ConversationChannel.operator !=(AgentCore.AspNetCore.Voice.ConversationChannel? left, AgentCore.AspNetCore.Voice.ConversationChannel? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationChannel.operator ==(AgentCore.AspNetCore.Voice.ConversationChannel? left, AgentCore.AspNetCore.Voice.ConversationChannel? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationChannelContext.operator !=(AgentCore.AspNetCore.Voice.ConversationChannelContext? left, AgentCore.AspNetCore.Voice.ConversationChannelContext? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationChannelContext.operator ==(AgentCore.AspNetCore.Voice.ConversationChannelContext? left, AgentCore.AspNetCore.Voice.ConversationChannelContext? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Barge.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Barge? left, AgentCore.AspNetCore.Voice.ConversationInput.Barge? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Barge.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Barge? left, AgentCore.AspNetCore.Voice.ConversationInput.Barge? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Keypress.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Keypress? left, AgentCore.AspNetCore.Voice.ConversationInput.Keypress? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Keypress.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Keypress? left, AgentCore.AspNetCore.Voice.ConversationInput.Keypress? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Utterance.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Utterance? left, AgentCore.AspNetCore.Voice.ConversationInput.Utterance? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Utterance.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Utterance? left, AgentCore.AspNetCore.Voice.ConversationInput.Utterance? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.operator !=(AgentCore.AspNetCore.Voice.ConversationInput? left, AgentCore.AspNetCore.Voice.ConversationInput? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.operator ==(AgentCore.AspNetCore.Voice.ConversationInput? left, AgentCore.AspNetCore.Voice.ConversationInput? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationSpeechPairing.Validate(AgentCore.Application.Configuration.Schema.ConversationProviderConfiguration! conversation, AgentCore.Application.Configuration.Schema.SpeechProviderConfiguration! speech, AgentCore.AspNetCore.Voice.IConversationAdapter! selectedConversation) -> void +virtual AgentCore.AspNetCore.Voice.ConversationInput.EqualityContract.get -> System.Type! +virtual AgentCore.AspNetCore.Voice.ConversationInput.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool +virtual AgentCore.AspNetCore.Voice.ConversationInput.PrintMembers(System.Text.StringBuilder! builder) -> bool +override AgentCore.AspNetCore.Voice.ConversationInput.Started.$() -> AgentCore.AspNetCore.Voice.ConversationInput.Started! +override AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(object? obj) -> bool +override AgentCore.AspNetCore.Voice.ConversationInput.Started.GetHashCode() -> int +override AgentCore.AspNetCore.Voice.ConversationInput.Started.ToString() -> string! +static AgentCore.AspNetCore.Voice.ConversationInput.Started.operator !=(AgentCore.AspNetCore.Voice.ConversationInput.Started? left, AgentCore.AspNetCore.Voice.ConversationInput.Started? right) -> bool +static AgentCore.AspNetCore.Voice.ConversationInput.Started.operator ==(AgentCore.AspNetCore.Voice.ConversationInput.Started? left, AgentCore.AspNetCore.Voice.ConversationInput.Started? right) -> bool +override sealed AgentCore.AspNetCore.Voice.ConversationInput.Barge.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool +override sealed AgentCore.AspNetCore.Voice.ConversationInput.Keypress.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool +override sealed AgentCore.AspNetCore.Voice.ConversationInput.Utterance.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool +override sealed AgentCore.AspNetCore.Voice.ConversationInput.Started.Equals(AgentCore.AspNetCore.Voice.ConversationInput? other) -> bool diff --git a/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs b/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs index 93ed2933..26993bc5 100644 --- a/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs +++ b/src/AgentCore.AspNetCore/Sessions/AgentCoreAgentSessionStore.cs @@ -1,17 +1,15 @@ -using System.Text.Json; using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; namespace AgentCore.AspNetCore.Sessions { /// - /// The framework's session seam over the continuation map that lives in the conversation store. + /// The framework's session seam over the conversation store. /// public sealed class AgentCoreAgentSessionStore : AgentSessionStore { - private const string PointerProperty = "$continuationPointer"; - private readonly IConversationStore _conversations; /// Creates the seam over one conversation store. @@ -23,65 +21,80 @@ public AgentCoreAgentSessionStore(IConversationStore conversations) _conversations = conversations; } - /// Reads whether anything is filed under one continuation id. + /// Reads whether a continuation id names a conversation, directly or through a response id. /// The conversation id or response id to look up. /// Cancels the read. - /// when a later load would find an envelope. + /// when a later resume would find the conversation to open. public async ValueTask ContainsAsync(string sessionStoreId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(sessionStoreId); - return await _conversations.GetContinuationAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) - is not null; + return await ResolveConversationIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) is not null; } - /// - public override ValueTask SaveSessionAsync( - AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + /// Resolves a continuation id to the conversation it names. + /// A conversation id, or a response id that continues one. + /// Cancels the reads. + /// + /// The conversation id: itself when it already names a conversation + /// row, the row a response id continues, or when it names neither. + /// + public async ValueTask ResolveConversationIdAsync(string sessionStoreId, CancellationToken cancellationToken = default) { - return SaveSessionForConversationAsync(agent, sessionStoreId, sessionStoreId, session, cancellationToken); + ArgumentNullException.ThrowIfNull(sessionStoreId); + + if (await _conversations.FindContinuationAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) is { } owner) + { + return owner; + } + + return await _conversations.GetAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) is not null + ? sessionStoreId + : null; } - /// Files the session under one continuation id, on behalf of one conversation. - /// The agent the session belongs to. - /// The conversation id or response id to file the envelope under. - /// The conversation the continuation belongs to. - /// The session to file. + /// Records that one response id continues one conversation. + /// The id minted for a turn's answer. + /// The conversation the response id continues. /// Cancels the write. - public async ValueTask SaveSessionForConversationAsync( - AIAgent agent, string sessionStoreId, string conversationId, AgentSession session, CancellationToken cancellationToken) + public ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(agent); - ArgumentNullException.ThrowIfNull(sessionStoreId); + ArgumentNullException.ThrowIfNull(responseId); ArgumentNullException.ThrowIfNull(conversationId); - ArgumentNullException.ThrowIfNull(session); - JsonElement envelope = await agent - .SerializeSessionAsync(session, cancellationToken: cancellationToken) - .ConfigureAwait(false); - - await _conversations.SaveContinuationAsync(sessionStoreId, conversationId, envelope, cancellationToken).ConfigureAwait(false); + return _conversations.SaveContinuationAsync(responseId, conversationId, cancellationToken); } /// - /// Points one continuation id at another, in place of a full envelope. A resume through the - /// pointer id always reads the latest state, without a second copy of it. + /// Records that a response id continues the conversation MAF's SaveSessionAsync path just + /// filed a session for. Nothing is written when the id already names the conversation itself: a + /// conversation resumes through without a row of its own. /// - /// The continuation id the pointer is filed under. - /// The conversation the pointer belongs to. - /// The continuation id holding the full envelope. + /// The agent the session belongs to. + /// The id the caller is filing under. + /// The session this store opened onto. /// Cancels the write. - public ValueTask SavePointerAsync( - string sessionStoreId, string conversationId, string target, CancellationToken cancellationToken) + /// is not one created. + public override ValueTask SaveSessionAsync( + AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(sessionStoreId); - ArgumentNullException.ThrowIfNull(conversationId); - ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(session); + + if (session is not AgentCoreAgentSession own) + { + throw new ArgumentException( + $"Incompatible session type: {session.GetType()}. Only a session {nameof(AgentCoreAgent)} " + + "created can be filed.", + nameof(session)); + } - JsonElement pointer = JsonSerializer.SerializeToElement( - new Dictionary { [PointerProperty] = target }); + string conversationId = own.Conversation.ConversationId; - return _conversations.SaveContinuationAsync(sessionStoreId, conversationId, pointer, cancellationToken); + return string.Equals(sessionStoreId, conversationId, StringComparison.Ordinal) + ? ValueTask.CompletedTask + : _conversations.SaveContinuationAsync(sessionStoreId, conversationId, cancellationToken); } /// @@ -91,38 +104,32 @@ public override async ValueTask GetSessionAsync( ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(sessionStoreId); - JsonElement? envelope = await _conversations.GetContinuationAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - - if (envelope is null) - { - return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - } - - if (TryReadPointerTarget(envelope.Value, out string? target)) - { - envelope = await _conversations.GetContinuationAsync(target, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException( - $"Continuation '{sessionStoreId}' points at '{target}', which no longer names an envelope."); - } + // Unknown to store 0: opened under the id itself, not a random one, so this key resolves by + // GetAsync forever after, and MAF's own SaveSessionAsync writes no response row for it at all + // — a made-up id would need one, and the retention sweep would eventually take it. + string conversationId = await ResolveConversationIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false) + ?? sessionStoreId; - return await agent - .DeserializeSessionAsync(envelope.Value, cancellationToken: cancellationToken) - .ConfigureAwait(false); + return await GetSessionForConversationAsync(agent, conversationId, cancellationToken).ConfigureAwait(false); } - /// Reads the target a pointer row names, or answers for a full envelope. - private static bool TryReadPointerTarget(JsonElement envelope, out string target) + /// Opens the conversation a resolved id names. + /// The agent to open the conversation on. + /// The conversation to open, or start under that id if it is new. + /// Cancels the open. + /// is not one . + public ValueTask GetSessionForConversationAsync( + AIAgent agent, string conversationId, CancellationToken cancellationToken = default) { - if (envelope.ValueKind == JsonValueKind.Object - && envelope.TryGetProperty(PointerProperty, out JsonElement value) - && value.ValueKind == JsonValueKind.String) - { - target = value.GetString()!; - return true; - } + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(conversationId); - target = ""; - return false; + return agent is AgentCoreAgent own + ? own.CreateSessionAsync(conversationId, cancellationToken) + : throw new ArgumentException( + $"Incompatible agent type: {agent.GetType()}. Only {nameof(AgentCoreAgent)} can resume a " + + "conversation by id.", + nameof(agent)); } /// diff --git a/src/AgentCore.AspNetCore/Sessions/ConversationSessionSweeper.cs b/src/AgentCore.AspNetCore/Sessions/ConversationSessionSweeper.cs deleted file mode 100644 index bea3aea4..00000000 --- a/src/AgentCore.AspNetCore/Sessions/ConversationSessionSweeper.cs +++ /dev/null @@ -1,76 +0,0 @@ -using AgentCore.AspNetCore.DependencyInjection.Startup; -using AgentCore.Application.Sessions.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using AgentCore.Application.Ports; - -namespace AgentCore.AspNetCore.Sessions -{ - /// - /// Runs the idle sweep of every for as long as the host is up. - /// - internal sealed class ConversationSessionSweeper( - IServiceProvider services, TimeProvider timeProvider, ILogger logger) : BackgroundService - { - /// How often the sweep runs. - private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5); - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - EntryRegistry entries = services.GetRequiredService(); - - using PeriodicTimer timer = new(Interval, timeProvider); - - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) - { - // A sweep that throws must not end the loop: the sessions it could not close are still - // held, and the next tick is the only thing that will try them again. - try - { - List? faults = null; - foreach (IConversationSessions store in entries.ConversationSessions.Values) - { - if (store is not InMemoryConversationSessions memory) - { - continue; - } - - try - { - await memory.SweepAsync(stoppingToken).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - faults ??= []; - faults.Add(fault); - } - } - - if (faults is { Count: > 0 }) - { - throw new AggregateException(faults); - } - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - ConversationSessionSweeperLog.SweepFaulted(logger, fault); - } - } - } - } - - /// Every line the idle sweep writes. - internal static partial class ConversationSessionSweeperLog - { - /// A sweep could not end one or more of the sessions it visited. - /// The logger of the sweeper. - /// The cause, or the causes of a sweep that met more than one. - [LoggerMessage( - EventId = 1, - Level = LogLevel.Error, - Message = "the idle sweep could not end every session it visited, so those sessions are still held.")] - public static partial void SweepFaulted(ILogger logger, Exception exception); - } -} diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs new file mode 100644 index 00000000..656df3de --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/OutboundItem.cs @@ -0,0 +1,13 @@ +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +{ + /// One item queued for the write loop, carrying the reply generation it was written under. + /// + /// The reply generation was queued under, or for a + /// frame no reply owns — nothing writes one of those yet, but the write loop's gate only applies + /// when a generation is present, so a future frame outside the reply lifecycle (a close handoff, + /// for one) can opt out by carrying none. This type never reaches : + /// only is serialized, so nothing here changes the wire. + /// + /// The frame to serialize and send: a today. + internal readonly record struct OutboundItem(long? Generation, object Frame); +} diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs new file mode 100644 index 00000000..fe091591 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/RelayProtocolException.cs @@ -0,0 +1,14 @@ +using System.Net.WebSockets; + +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +{ + /// The relay broke the contract, and the socket must close with a reason. + /// The close status the vendor should see. + /// Why the endpoint refused. + internal sealed class RelayProtocolException(WebSocketCloseStatus status, string message) + : Exception(message) + { + /// Gets the status the socket closes with. + public WebSocketCloseStatus Status { get; } = status; + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs index 7fcc6bc8..93e61cee 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConnection.cs @@ -1,12 +1,6 @@ using System.Net.WebSockets; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading.Channels; -using AgentCore.Application.Conversation; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.AspNetCore.Conversation; using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Voice; using AgentCore.Domain.Audit; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -19,58 +13,24 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay /// One socket, one conversation. /// /// - /// - /// Three tasks run for the life of the socket. The read loop parses frames and never waits for a - /// turn, because a turn that blocked the loop would make the interrupt frame unreachable - /// and barge-in impossible. The write loop is the only caller of - /// , - /// because a WebSocket supports exactly one send at a time. The turn task streams the reply into - /// the write channel. - /// - /// - /// All three read one connection-scoped token, cancelled once for any reason the socket is going - /// away: the request aborted, the host is stopping, or one of the three tasks itself ended. - /// Nothing here waits on HttpContext.RequestAborted once teardown starts, because ASP.NET - /// Core recycles the context once the response completes and that token can then throw or name a - /// later request. - /// - /// - /// The adapter owns the vendor schema and the core does not, so D8 holds: this class translates - /// frames onto and nothing else. - /// - /// - /// One object fills both halves of the conversation's , because one socket - /// carries both directions for this vendor. Nothing above the ports can tell, and nothing may ask. - /// The speaking side is below: every reply reaches the write loop - /// through . The last of the two barge-in gates is this class's business - /// and nobody else's — speaks in turn ids and never hands one over, - /// so this class keeps a monotonic reply generation instead, stamps every item it queues with the - /// generation it was queued under, and raises that generation in . The write - /// loop then drops any item at or below the raised one, which is the same comparison the turn ids - /// used to answer, asked in the transport's own vocabulary. - /// - /// - /// One narrowing comes with that seam, and it is written down rather than hidden: a fragment the - /// arbiter had already decided to speak, but which only reaches after - /// raised the generation, is stamped with the new one and reaches the - /// caller. A port carries no reply identity to tell that fragment from the first fragment of the - /// next reply, and the window is the few instructions between the arbiter's own check and that conversation. - /// + /// This type owns the socket and its teardown. and + /// translate the wire, and + /// owns the turns. /// - internal sealed class TelnyxRelayConnection : IConversationInputPort, IConversationOutputPort + internal sealed class TelnyxRelayConnection : IAsyncDisposable { private readonly TelnyxRelayOptions _options; private readonly ILogger _logger; - private readonly Channel _outbound; + private readonly TelnyxRelayInput _input; + + private readonly TelnyxRelayOutput _output = new(); + + private readonly VoiceConversationLoop _loop; private readonly CancellationTokenSource _cancellation; - // The same token every loop reads, captured once. Read off the field and never off - // _cancellation.Token by anything that can outlive teardown: RunAsync disposes that source on - // its way out, and a disposed source's Token getter throws, while the struct captured here goes - // on answering "already cancelled" for as long as anyone holds it. private readonly CancellationToken _connectionToken; private readonly IHostApplicationLifetime _lifetime; @@ -79,42 +39,11 @@ internal sealed class TelnyxRelayConnection : IConversationInputPort, IConversat private readonly JsonWebSocketPump _pump; - // The last of the two barge-in gates, in this connection's own vocabulary. SpeakAsync stamps - // every item it queues with this counter plus one and StopAsync raises the counter itself when a - // barge-in stops the reply; the write loop then drops anything at or below it. It lives on the - // connection, alongside the channel the write loop reads, because the write loop is the one that - // reads it and a second setup frame must not reset what a barge-in already cut off. - private long _replyGeneration; - - // Zero until something takes the inbound stream, and one afterwards, forever. ListenAsync - // promises one consumer for the life of the port, and this is what a second call trips over. - private int _listening; - - private volatile ConversationSession? _session; - - // Resolved once per socket from the stamped entry, then cached: every session open, touch, - // and close on this connection answers on the same entry. - private volatile IConversationSessions? _entrySessions; + private readonly JsonWebSocketSender _sender; private const string BeforeSetupConversationId = "(before setup)"; - // volatile for the same reason _session is: the read loop assigns it when the setup frame - // arrives, and teardown reads it from another task to find the last turn. - private volatile ConversationTurnArbiter? _arbiter; - - private bool _loggedPromptBeforeSetup; - - private bool _loggedMalformedInterrupt; - - private bool _loggedSecondSetup; - - /// Reads this socket's entry session store, resolved once from the URL's entry. - /// The store for the entry the URL named. - private IConversationSessions EntrySessions() - { - return _entrySessions - ?? throw new InvalidOperationException("This socket resolved no session store."); - } + private const string ConversationInUseDescription = "the conversation is in use by another entry"; private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOptions options, ILogger logger) { @@ -122,10 +51,6 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt _logger = logger; - // A dropped socket ends this conversation, and so does the host shutting down. Both stop the read - // loop, the write loop, and any turn the same way, through the one token everything below - // reads. IHostApplicationLifetime is resolved here, from the request's own provider, and - // never at MapTelnyxRelay time: nothing is bound to it until a conversation actually arrives. _lifetime = http.RequestServices.GetRequiredService(); _cancellation = CancellationTokenSource.CreateLinkedTokenSource( @@ -134,37 +59,16 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt _connectionToken = _cancellation.Token; - // AddAgentCore registers this from options.TimeProvider, or TimeProvider.System when the - // host bound none — the same clock ConversationSessionFactory already reads for conversationDurationSeconds. - // Resolving it here, rather than reading TimeProvider.System directly, is what lets a test - // own the idle deadline below by binding that one option. TimeProvider timeProvider = http.RequestServices.GetRequiredService(); - // Bounded, so a slow relay slows the turn loop instead of growing a queue without a bound. - _outbound = Channel.CreateBounded(new BoundedChannelOptions(256) - { - FullMode = BoundedChannelFullMode.Wait, - SingleReader = true, - SingleWriter = false, - }); - - // The observer is vendor-free, so the two faults that are the relay's own doing reach it - // through ClassifyTelnyxFault rather than through a catch clause of its own. The conversation id is - // a function because the session only exists once the setup frame has arrived, which is - // after this constructor and after some of the lines the observer logs. + _input = new TelnyxRelayInput(logger, () => ConversationIdForLog); + _observer = new ConnectionTaskObserver( - () => _session?.ConversationId ?? BeforeSetupConversationId, + () => ConversationIdForLog, (conversationId, taskName) => TelnyxRelayLog.TeardownTimedOut(_logger, conversationId, taskName), LogFault, ClassifyTelnyxFault); - // The pump owns the socket from here on: the read loop, the write loop, and the one close - // this connection makes are all its, and none of the three names a vendor. The three seams - // that would have are the delegates below — the exception the pump raises when the relay - // breaks the contract, so DetermineCloseStatus still recognises it off the read loop's own - // task, and the three log lines that name the relay by name. Each log callback closes over - // _session for the same reason the observer's own conversationId does: the id only exists once the - // setup frame has arrived, which is after this constructor. _pump = new JsonWebSocketPump( socket, new JsonWebSocketPumpOptions( @@ -172,19 +76,34 @@ private TelnyxRelayConnection(HttpContext http, WebSocket socket, TelnyxRelayOpt _options.IdleTimeout, _options.CloseTimeout, (status, message) => new RelayProtocolException(status, message), - frameType => TelnyxRelayLog.UnknownFrameType(_logger, frameType, _session?.ConversationId ?? BeforeSetupConversationId), - frameType => TelnyxRelayLog.FrameBodyRefused(_logger, frameType, _session?.ConversationId ?? BeforeSetupConversationId), - () => TelnyxRelayLog.IdleTimeoutReached(_logger, _session?.ConversationId ?? BeforeSetupConversationId)), + frameType => TelnyxRelayLog.UnknownFrameType(_logger, frameType, ConversationIdForLog), + frameType => TelnyxRelayLog.FrameBodyRefused(_logger, frameType, ConversationIdForLog), + () => TelnyxRelayLog.IdleTimeoutReached(_logger, ConversationIdForLog)), timeProvider, _connectionToken); + + _sender = new JsonWebSocketSender(socket, _options.CloseTimeout, _connectionToken); + + _loop = new VoiceConversationLoop( + http.RequestServices.GetRequiredService().Entries.Sessions, + ConversationEndpointRouteBuilderExtensions.EntryOf(http), + _output, + _observer, + timeProvider, + logger, + _options.CloseTimeout, + _connectionToken, + _options.Voice); } + private string ConversationIdForLog => _loop?.ConversationId ?? BeforeSetupConversationId; + /// Runs one conversation to its end. /// The request that carried the handshake. /// The accepted socket. /// What the endpoint may do. /// A task that completes when the socket is closed. - public static Task RunAsync(HttpContext http, WebSocket socket, TelnyxRelayOptions options) + public static async Task RunAsync(HttpContext http, WebSocket socket, TelnyxRelayOptions options) { ArgumentNullException.ThrowIfNull(http); ArgumentNullException.ThrowIfNull(socket); @@ -194,43 +113,25 @@ public static Task RunAsync(HttpContext http, WebSocket socket, TelnyxRelayOptio .GetRequiredService() .CreateLogger("AgentCore.TelnyxRelay"); - TelnyxRelayConnection connection = new(http, socket, options, logger) - { - _entrySessions = http.RequestServices - .GetRequiredService() - .Entries.ForSessions(ConversationEndpointRouteBuilderExtensions.EntryOf(http)) - }; + await using TelnyxRelayConnection connection = new(http, socket, options, logger); - return connection.RunAsync(); + await connection.RunAsync().ConfigureAwait(false); } private async Task RunAsync() { - // Both loops belong to the pump, and both seams into this class are named methods rather - // than bodies: ParseFrame is the vendor's own reader, and DispatchAsync is what one parsed - // frame means to this conversation. The cast is safe because ParseFrame is the only thing that ever - // puts a frame into a FrameOutcome, and it only ever puts a RelayFrame there. - Task reading = _pump.ReadLoopAsync(ParseFrame, frame => DispatchAsync((RelayFrame)frame)); - Task writing = _pump.WriteLoopAsync(_outbound.Reader, EncodeOutbound); + Task reading = _pump.ReadLoopAsync(ParseFrame, AcceptAsync); + Task writing = _sender.WriteLoopAsync(_output.Reader, _output.Encode, _output.SendGate); + Task listening = _loop.RunAsync(_input.ListenAsync(CancellationToken.None)); try { - _ = await Task.WhenAny(reading, writing).ConfigureAwait(false); + _ = await Task.WhenAny(reading, writing, listening).ConfigureAwait(false); } finally { - // The disposal below must run on every exit from here down, including a throw out of - // Cancel() itself or IConversationSessions.CloseAsync: _cancellation links to ApplicationStopping, - // which lives for the whole process, and only Dispose() releases that registration. try { - // One of the two loops just ended, for whatever reason, so the socket is going - // away. Cancelling here stops the other loop's wait and any turn still streaming, - // rather than leaving them to read a recycled HttpContext.RequestAborted after this - // method returns. Cancel() itself is guarded: it runs with throwOnFirstException: - // false, so every callback still runs and both loops are already cancelled before a - // callback fault can reach this catch, and losing the rest of teardown below would - // otherwise strand this conversation in the store for the life of the process. try { await _cancellation.CancelAsync().ConfigureAwait(false); @@ -239,113 +140,41 @@ private async Task RunAsync() { ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.CancellationFaulted( _logger, - _session?.ConversationId ?? BeforeSetupConversationId, + ConversationIdForLog, fault)); } - // The read loop must actually finish — and any session it was still creating must be - // visible on this task — before _session is read for removal below. Awaiting it here, - // rather than trusting Task.WhenAny already returned, is also what surfaces a fault - // that raced the write loop instead of leaving it unobserved. + // Nothing queued from here on can be sent, and a speech still blocked on a full queue would + // otherwise hold the drain below until its interruption backstop. + _output.Complete(); + await _observer.ObserveAsync(reading, ConnectionTaskKind.ReadLoop).ConfigureAwait(false); - // The turn must stop writing before the channel is completed under it. Completing the - // writer first would let a write that was already in flight throw ChannelClosedException - // into a task nothing observes. Bounded: cancellation reaches a well-behaved turn, but - // a turn that honours cancellation yet takes longer than CloseTimeout to unwind — or a - // chat client that ignores its token altogether, such as BlockingChatClient in the test - // fakes — must not be able to wedge teardown forever. - // - // The last turn is read through ConversationTurnArbiter.CurrentTurn, which takes the same - // lock every writer of it takes, and never off a field directly: the arbiter's own - // RunPendingPrompt can reassign it from a turn's own task, off the read loop, so - // teardown here is no longer the only writer's own reader. That property's remarks - // carry the rest of the reasoning, including what stops the reverse case — a fresh - // turn appearing after teardown already read it. Null before the first setup frame, - // because the arbiter is built with the session. - Task lastTurn = _arbiter?.CurrentTurn ?? Task.CompletedTask; - - await _observer.ObserveAsync(lastTurn, ConnectionTaskKind.Turn, _options.CloseTimeout).ConfigureAwait(false); - - _ = _outbound.Writer.TryComplete(); - - // Both reading and lastTurn are fully observed by this point — ObserveAsync already - // awaited each of them inside its own try, which is what makes it safe to read - // .Exception here without raising a fresh UnobservedTaskException. Determined now, - // after teardown already knows how the read loop and the last turn each ended, and - // before the one CloseOutputAsync call the pump makes, because a status decided - // any later could not still change what the vendor already received. writing is not - // yet observed at this point — it is only awaited after the close below — so passing - // it in only lets DetermineCloseStatus see a fault it already has, never one that - // has not happened yet; see the remarks on that method for what this can and cannot - // catch. - (WebSocketCloseStatus status, string? description) = DetermineCloseStatus(reading, lastTurn, writing); - - // No bounded wait for the write loop here: _cancellation already cancelled above, and - // the write loop reads the channel with that same token, so it is already unblocking - // on its own. CloseAsync is what actually ends a send stuck on relay backpressure — it - // aborts the socket on its own timeout — so the write loop is observed after it, not - // waited for before it. A throw out of CloseAsync itself must not skip that - // observation or the store removal below, so it is caught and logged here instead of - // left to propagate. + _input.Complete(); + + await _observer.ObserveAsync(listening, ConnectionTaskKind.ReadLoop).ConfigureAwait(false); + + Task drained = _loop.DrainAsync(); + + await _observer.ObserveAsync(drained, ConnectionTaskKind.SessionClose, _options.CloseTimeout).ConfigureAwait(false); + + (WebSocketCloseStatus status, string? description) = DetermineCloseStatus(reading, listening, drained, writing); + try { - await _pump.CloseAsync(status, description).ConfigureAwait(false); + await _sender.CloseAsync(status, description).ConfigureAwait(false); } catch (Exception fault) { ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.CloseFaulted( _logger, - _session?.ConversationId ?? BeforeSetupConversationId, + ConversationIdForLog, fault)); } await _observer.ObserveAsync(writing, ConnectionTaskKind.WriteLoop).ConfigureAwait(false); - if (_session is { } session) - { - // The chain closes before the session leaves the store, and never after: §11 - // item 6 makes conversation.ended the last event of every conversation, and the session is what - // writes it. The reason comes from the status just decided rather than from a - // second reading of the same tasks, so the vendor's close frame and the audit - // row can never disagree about how this conversation ended. A conversation that already closed - // its own chain from a terminal stage answers false here and keeps the - // agent.completed it wrote: EndConversation is idempotent, and nothing on this path may - // give one conversation two endings. - // - // Guarded for the same reason CloseAsync above is. §7.1 forbids teardown - // throwing out of the request handler, and the close below must run whatever - // happens here — it is what waits for the words the last turn still owed - // store 1, and nothing else can. The event is - // handed to observers that swallow their own faults and the reason is a member - // of a closed set this class picks itself, so the only cause left is the clock - // the session reads; a missing last event is a gap in one chain, and losing the - // rest of teardown would be a leak in every conversation after it. - try - { - _ = session.EndConversation(EndReasonOf(status)); - } - catch (Exception fault) - { - ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.ConversationEndFaulted( - _logger, - session.ConversationId, - fault)); - } - - // Closing waits for the words this conversation still owes store 1 and only then drops - // the session; IConversationSessions.CloseAsync owns that order. - // - // Bounded and swallowed for the same reason the close above is: a store that - // stops answering must cost this conversation its last rows, not wedge teardown and - // strand the session in the store for the life of the process. - await _observer - .ObserveAsync( - CloseSessionAsync(session.ConversationId), - ConnectionTaskKind.SessionClose, - _options.CloseTimeout) - .ConfigureAwait(false); - } + await _loop.EndAsync(EndReasonOf(status)).ConfigureAwait(false); } finally { @@ -354,23 +183,19 @@ await _observer } } - /// Ends one conversation, as a task the observer can bound and watch. - /// The conversation that ended. - /// A task that completes once the words are written and the session is gone. - private async Task CloseSessionAsync(string conversationId) - { - await EntrySessions().CloseAsync(conversationId, CancellationToken.None).ConfigureAwait(false); - } - /// Works out the status and the description the vendor sees on the close frame. /// /// The read loop's task, already fully observed by . /// - /// - /// The last turn's task, already fully observed by . + /// + /// The voice loop's task, already fully observed. A fault there closes the socket the same way a fault in + /// the read loop does. + /// + /// + /// The speech teardown's task, already fully observed by . /// /// - /// The write loop's task. Unlike and , this + /// The write loop's task. Unlike and , this /// one is not yet observed — RunAsync only awaits it after the close this method's result /// feeds. Its is still safe to read without observing it first: /// that flag reflects the task's current state the moment it is read, and reading it does not @@ -378,42 +203,10 @@ private async Task CloseSessionAsync(string conversationId) /// awaiting or reading would. /// /// The status to close with, and the description to send alongside it. - /// - /// - /// Both and are already completed and - /// already observed by the time this runs — ObserveAsync awaited each of them inside its own - /// try — so reading their here raises nothing new; it only reads - /// what already happened. - /// - /// - /// The checks are ordered by how specific each cause is. A - /// names exactly why the endpoint refused the conversation, so it wins over everything else. A turn or - /// the write loop faulting with anything other than its own cancellation is this connection's - /// own defect, not the caller's or the vendor's, so either counts before the host-stopping check - /// below even notices that is, by this point in teardown, always - /// already cancelled for every possible reason at once. Host-stopping is checked directly - /// against rather than against which task happened to notice the - /// cancellation first, because once teardown's own call to _cancellation.Cancel() has - /// run, every task is cancelled regardless of why teardown started, and none of their own - /// completions can still tell "the host is stopping" apart from "the other loop just ended, so - /// this cancelled too." A dropped socket with no close frame — - /// — and a read loop that simply - /// returned once it saw the relay's own close frame both fall through to the same default: an - /// ordinary end of conversation. - /// - /// - /// The check only catches a write loop that has already faulted at - /// the moment this method runs. It is not exhaustive: the write loop is still running at this - /// point in the common case, and a fault it raises after this line — right up through the - /// SendAsync the close itself makes — cannot change a status already chosen and already - /// on its way to the vendor. That gap is an accepted limit of observing - /// before the close rather than after it, which the teardown order this method must not disturb - /// already requires. - /// - /// private (WebSocketCloseStatus Status, string? Description) DetermineCloseStatus( Task reading, - Task lastTurn, + Task listening, + Task drained, Task writing) { if (reading.IsFaulted && reading.Exception.GetBaseException() is RelayProtocolException protocol) @@ -421,21 +214,27 @@ private async Task CloseSessionAsync(string conversationId) return (protocol.Status, protocol.Message); } - if (lastTurn.IsFaulted || writing.IsFaulted) + if (drained.IsFaulted || writing.IsFaulted) { return (WebSocketCloseStatus.InternalServerError, null); } + if (_loop.WasRefused) + { + return (WebSocketCloseStatus.PolicyViolation, ConversationInUseDescription); + } + if (_lifetime.ApplicationStopping.IsCancellationRequested) { return (WebSocketCloseStatus.EndpointUnavailable, null); } - if (reading.IsFaulted - && reading.Exception.GetBaseException() is not WebSocketException - { - WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely, - }) + if (listening.IsFaulted + || (reading.IsFaulted + && reading.Exception.GetBaseException() is not WebSocketException + { + WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely, + })) { return (WebSocketCloseStatus.InternalServerError, null); } @@ -446,55 +245,6 @@ private async Task CloseSessionAsync(string conversationId) /// Reads the ending one close status reports, as one member of the closed set. /// What already decided this conversation closes with. /// The reason the last event of the chain carries. - /// - /// - /// §11 item 6 and T55/T56 ask every call to end its chain in conversation.ended with a reason from - /// the closed set of four, and only this adapter can supply two of them: the turn loop closes its - /// own chain with when the stage machine reaches a - /// terminal stage, and belongs to the Conversation Control - /// path of §4.3 that voice slice 2 adds and this build does not have. What is left for a socket - /// that ended is the caller, or a fault. - /// - /// - /// The answer is read off the status rather than recomputed from the three tasks, so one decision - /// serves both the close frame the vendor sees and the row a report reads years later. That also - /// keeps this method total over a status set only chooses from. - /// - /// - /// is a hang-up, not a fault, and that - /// is the load-bearing decision here. A socket that stopped with no close frame is what - /// already folds into - /// alongside a read loop that saw the relay's own close frame — its remarks conversation both "an ordinary - /// end of conversation" — and what already logs through - /// at Information rather than as a defect. - /// This method agrees with both, and it has to: a caller who hangs up and a caller whose phone - /// loses signal look identical on this socket, the vendor never reconnects either of them, and - /// the audit table would be worth very little if the ordinary way a conversation ends were counted as a - /// system fault. §7.1's rule that a bad frame must never drop a conversation points the same way — the - /// adapter's default reading of an ended socket is the conversation ending, not the endpoint failing. - /// The one place this reading is contradicted is the remark on - /// itself, which lists "a dropped relay socket" among its causes; that sentence predates the - /// close-status reasoning above, and it is left alone here rather than edited, because the enum is - /// the core's and this decision is the adapter's. - /// - /// - /// The idle deadline lands here too, and for the same reason. A read loop that ends on - /// IdleTimeout returns rather than throwing, so it reaches - /// and is recorded as a hang-up — which is - /// exactly what already says a silent socket is: - /// a conversation that is already over. - /// - /// - /// Everything else is , and that includes two endings worth - /// naming. A protocol violation closes with the status - /// carries: the relay broke the contract and this endpoint refused the conversation, which no caller - /// chose. A host that stops closes with : - /// the process went away underneath a live conversation, which no caller chose either. Neither is a - /// hang-up, neither is the agent finishing, and neither is a transfer, so within a set of four - /// the honest answer is the fault — recorded as one rather than quietly counted as a caller who - /// decided to leave. - /// - /// private static ConversationEndReason EndReasonOf(WebSocketCloseStatus status) { return status is WebSocketCloseStatus.NormalClosure @@ -503,36 +253,23 @@ private static ConversationEndReason EndReasonOf(WebSocketCloseStatus status) } /// Logs the two faults that are the vendor's doing rather than this endpoint's defect. - /// The exception a loop or a turn ended with. + /// The exception a loop or the session close ended with. /// Which task faulted. /// The id of the conversation, or a placeholder before setup. /// /// when this method logged itself, and /// to leave it to . /// - /// - /// The classify hook conversations before its own general clause. - /// Both causes below name a vendor type, which is why they stay here rather than moving into - /// that observer with the rest of the teardown machinery. - /// private bool ClassifyTelnyxFault(Exception fault, ConnectionTaskKind kind, string conversationId) { switch (fault) { case RelayProtocolException protocol: - // Malformed JSON or an oversized frame: the relay broke the contract, not a defect - // this endpoint owns. DetermineCloseStatus reads this same exception straight off - // the task afterward for the close status, so this line only has to log it — every - // other malformed-input line in this file (PromptBeforeSetup, FrameRefused, - // MalformedInterruptFrame) already logs at Warning, and LogFault's generic path - // would otherwise report this at Error, the same level as an unhandled bug. TelnyxRelayLog.RelayProtocolViolation(_logger, conversationId, protocol.Message); return true; case WebSocketException { WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely } when kind == ConnectionTaskKind.ReadLoop: - // The conversation dropped with no close frame. Section "close statuses" of task 7: that is - // an ordinary end of a conversation, not a fault, so it is worth a line but not an error one. TelnyxRelayLog.ConversationDroppedWithNoCloseFrame(_logger, conversationId); return true; @@ -541,7 +278,7 @@ private bool ClassifyTelnyxFault(Exception fault, ConnectionTaskKind kind, strin } } - /// Logs the fault of a read loop, a turn, or a write loop, whichever names. + /// Logs the fault of a read loop, a write loop, or the session close, whichever names. /// Which task faulted. /// The id of the conversation, or a placeholder before setup. /// The cause. @@ -556,10 +293,6 @@ private void LogFault(ConnectionTaskKind kind, string conversationId, Exception { switch (kind) { - case ConnectionTaskKind.Turn: - TelnyxRelayLog.TurnFaulted(_logger, conversationId, fault); - break; - case ConnectionTaskKind.WriteLoop: TelnyxRelayLog.WriteLoopFaulted(_logger, conversationId, fault); break; @@ -580,396 +313,22 @@ private void LogFault(ConnectionTaskKind kind, string conversationId, Exception /// Reads one inbound message with the vendor's own reader. /// The whole message, already reassembled by the pump. /// The frame, the name of a type this build does not model, or the name of one whose body would not bind. - /// - /// The parse seam of . says the - /// same three things does, so the - /// carries nothing the outcome does not: a frame is present exactly when that method returned - /// . - /// private static FrameOutcome ParseFrame(ReadOnlySpan utf8) { _ = TelnyxRelayFrameReader.TryRead(utf8, out RelayFrame? frame, out string? unknownType, out string? refusedType); return new FrameOutcome(frame, unknownType, refusedType); } - /// Writes one queued item as the frame to send, or drops it. - /// What a reply queued, and the generation it was queued under. - /// The write loop's own writer, already reset for this frame. - /// - /// when a whole frame was written, and to drop - /// without sending anything. - /// - /// - /// The encode seam of , which runs it immediately before - /// its one send. The gate below stays here, on this side of that seam, because a stale item is - /// stale in this connection's own vocabulary and in no transport's. The writer belongs to that - /// loop and is reused for every frame of the conversation, so nothing here keeps it, flushes it, or - /// holds what it wrote: the bytes are the loop's to send and the loop's to overwrite. - /// - private bool EncodeOutbound(OutboundItem item, Utf8JsonWriter writer) - { - // The write loop is the last gate, immediately before the one SendAsync call the pump - // makes for this connection. ConversationTurnArbiter's own check only narrows the race — it cannot - // stop a token already past it at the moment a barge-in raises the reply generation: - // one already queued, one a bounded channel's backpressure was about to release into the gap - // StopAsync's own drain just made, or one this loop had already dequeued and was about to - // send. All three carry the generation they were queued under, the same counter that drain - // raises, so whichever of them let a stale item this far, it stops here instead of reaching - // the caller. The gate is expressed in generations and never in the arbiter's turn ids: an - // item from a turn that simply ended, with a newer one already under way, is not stale, and - // a turn id alone cannot tell the two apart — a generation only ever moves when a barge-in - // actually stopped the audio. - // - // >=, not ==. _replyGeneration and every item's own Generation both only ever increase, - // so "the highest generation a barge-in has cut off is at or above this item's own" is - // the question that actually matches a conversation across two barge-ins: with == alone, an item of - // generation N already dequeued here, then gated after a second, later barge-in raises - // the mark to N+1, would compare N+1 != N, pass, and reach the caller — exactly the - // straggler this gate exists to stop. With no interrupt at all _replyGeneration stays - // 0, and a queued item's generation is always at least 1, so nothing is dropped that - // this comparison would not already drop with ==. The one behaviour change is a prior - // turn's trailing last: true frame, still queued when a later barge-in lands: it now - // drops instead of reaching the caller, which is exactly what StopAsync's own - // unconditional drain already does to that same frame whenever it is still queued at the - // moment of the interrupt rather than sent moments later. - if (item.Generation is { } generation && Interlocked.Read(ref _replyGeneration) >= generation) - { - return false; - } - - // The same options SerializeToUtf8Bytes was given, and the same bytes: TelnyxRelayJson sets - // no encoder and no indentation, so a default Utf8JsonWriter escapes and lays out this frame - // exactly as the writer JsonSerializer used to build per conversation did. - JsonSerializer.Serialize(writer, item.Frame, item.Frame.GetType(), TelnyxRelayJson.Options); - return true; - } - - private async Task DispatchAsync(RelayFrame frame) - { - switch (frame) - { - case RelayFrame.Setup setup: - await StartConversationAsync(setup).ConfigureAwait(false); - break; - - case RelayFrame.Prompt { Last: true } prompt: - await StartTurnAsync(prompt.VoicePrompt).ConfigureAwait(false); - break; - - case RelayFrame.Prompt: - // An interim transcript. The turn starts on the final one. - break; - - case RelayFrame.Interrupt interrupt: - HandleInterrupt(interrupt); - break; - - case RelayFrame.Dtmf: - // Never the digit itself. A keypad carries card numbers, PINs, and dates of birth, - // and the house rule in Log.cs is that no line carries what the caller said. - TelnyxRelayLog.DtmfReceived(_logger); - break; - - case RelayFrame.Error error: - // The vendor refused a frame this endpoint sent. That is our defect. - TelnyxRelayLog.FrameRefused(_logger, _session?.ConversationId ?? BeforeSetupConversationId, error.Description); - break; - - default: - // The reader hands back only the frames this build models; see TelnyxRelayFrameReader.TryRead. - break; - } - } - - private async Task StartConversationAsync(RelayFrame.Setup setup) - { - IConversationSessions sessions = EntrySessions(); - - // One socket carries one conversation, and the vendor sends one setup frame. A second one replaces - // the session rather than refusing the socket, because section 7.1 forbids dropping a conversation - // over a frame the vendor got wrong, and because the vendor's latest word about the conversation is - // the one to answer. The first session is closed here and not left behind: teardown only - // ever closes the session this connection currently holds, so this is the last moment - // anything waits for the words the replaced conversation still owed store 1. - // CancellationToken.None, because the close must still run while the connection is - // tearing down. - if (_session is { } replaced) - { - if (!_loggedSecondSetup) - { - _loggedSecondSetup = true; - TelnyxRelayLog.SecondSetupFrame(_logger, replaced.ConversationId); - } - - // Teardown will later close the session that replaced this one and never this one, so - // this is the last moment anything can wait for the writes it queued. Bounded and - // swallowed by the observer, so a store that stopped answering costs the replaced conversation - // its last rows rather than stalling the read loop for the rest of the connection. - // Through CloseSessionAsync, and never CloseAsync(...).AsTask(): a store that throws - // synchronously throws before AsTask() is ever reached, so the observer would not see - // it and §7.1's rule against dropping a conversation over a bad frame would be broken by the - // very frame that rule is about. An async method turns that throw into the task the - // observer bounds. - await _observer - .ObserveAsync( - CloseSessionAsync(replaced.ConversationId), - ConnectionTaskKind.SessionClose, - _options.CloseTimeout) - .ConfigureAwait(false); - } - - // conversationSessionId groups the legs of one logical conversation, so the id survives the warm transfer - // that slice 2 adds. A leg id would break there. - _session = await sessions.OpenAsync(setup.ConversationSessionId, _cancellation.Token).ConfigureAwait(false); - - // Built on the first setup frame and never again: a later one only tells the arbiter which - // call to answer from now on. One socket runs one turn at a time, and that promise belongs - // to this connection rather than to any one session of it — a second arbiter would bring - // its own _turnActive, its own held prompt, its own ids, and its own log-once flag, so a - // prompt arriving while the replaced session's turn still ran would find nothing in flight - // and start a second reply into the same channel. Rebinding keeps every one of those - // exactly where the old, connection-held state kept them. - if (_arbiter is { } arbiter) - { - arbiter.Rebind(_session); - return; - } - - // Not in the constructor: the arbiter runs turns against a session, and no session exists - // until this frame arrives. The output port handed over is this connection itself — one - // socket is both halves of this vendor's channel — and the arbiter cannot tell that from a - // port belonging to a separate synthesizer, which is the whole of D8. - // The two log callbacks are this vendor's own lines, bound here: the arbiter decides when a - // prompt is held and when a further one is dropped, and names no log event of its own. - _arbiter = new ConversationTurnArbiter( - _session, - this, - _observer, - conversationId => TelnyxRelayLog.PromptHeld(_logger, conversationId), - conversationId => TelnyxRelayLog.PendingPromptDropped(_logger, conversationId), - _connectionToken); - } - - private async Task StartTurnAsync(string text) - { - if (_arbiter is not { } arbiter) - { - // Log once for the conversation, not once for the frame. Same rule as the unknown-frame line. - if (!_loggedPromptBeforeSetup) - { - _loggedPromptBeforeSetup = true; - TelnyxRelayLog.PromptBeforeSetup(_logger); - } - - return; - } - - await KeepSessionAliveAsync().ConfigureAwait(false); - - // Discarded, never awaited. The arbiter's own task follows the turn to its end, and the read - // loop is what calls this: a read loop that waited for a turn could not receive the - // interrupt frame that ends it. The turn itself stays visible on CurrentTurn, which the next - // turn and teardown both observe. - _ = arbiter.StartTurnAsync(text); - } - - /// Tells the store this conversation is still being had. - /// A task that completes once the store has been read. - /// - /// This connection opens its session once and holds it for the whole conversation, so nothing else ever - /// reads it back. A store that expires an idle session counts a read as the one sign the conversation is - /// still live, so without this a conversation longer than that timeout is swept out from under the turn - /// about to run: its words are flushed, its session is dropped, and every turn after it runs on - /// an object no close can reach. A conversation nobody speaks on is ended by this socket's own - /// IdleTimeout instead, which is a real end and closes the chain. - /// The result is not used. It is the read itself that matters, and the session this connection - /// holds is the one the arbiter must keep running whatever the store answers. - /// - private async Task KeepSessionAliveAsync() - { - if (_session is not { } session) - { - return; - } - - // §7.1 forbids dropping a conversation over one frame, and this runs on the read loop. A store that - // throws costs this conversation its place in the sweep, never the conversation itself. - try - { - _ = await EntrySessions().TryGetAsync(session.ConversationId, _connectionToken).ConfigureAwait(false); - } - catch (Exception fault) when (fault is not OperationCanceledException) - { - ConnectionTaskObserver.SafeLog(() => TelnyxRelayLog.SessionTouchFaulted( - _logger, session.ConversationId, fault)); - } - } - - private void HandleInterrupt(RelayFrame.Interrupt interrupt) - { - if (_arbiter is not { } arbiter || _session is not { } session) - { - return; - } - - // Section 7.1's rule for an unknown frame type and for a prompt before setup applies here - // too: a frame the vendor got wrong must not drop the conversation. System.Text.Json enforces - // neither a missing property nor a negative number at run time, so a frame missing - // utteranceUntilInterrupt, or carrying a negative durationUntilInterruptMs, would otherwise - // reach ConversationSession.Interrupt's own ArgumentNullException or ArgumentOutOfRangeException - // uncaught and take the read loop down with it. Nothing here clamps the duration — a clamp - // is an estimate, and D28 exists to keep estimates out of this path. This guard stays here, - // and never moves into the arbiter: validating these two values is reading a vendor frame. - if (interrupt.UtteranceUntilInterrupt is null || interrupt.DurationUntilInterruptMs < 0) - { - if (!_loggedMalformedInterrupt) - { - _loggedMalformedInterrupt = true; - TelnyxRelayLog.MalformedInterruptFrame(_logger, session.ConversationId); - } - - return; - } - - // The vendor measured both values itself. D28 and item 6a: nothing here estimates either - // one. - _ = arbiter.Interrupt( - interrupt.UtteranceUntilInterrupt, - TimeSpan.FromMilliseconds(interrupt.DurationUntilInterruptMs)); - - // Logged last, once the raised id, the conversation to Interrupt, and the drain behind - // IConversationOutputPort.StopAsync have all already happened, so this line is proof the whole - // guard is in place — never the words the caller said or heard. - TelnyxRelayLog.InterruptReceived(_logger, session.ConversationId); - } - - /// - public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default) - { - return _outbound.Writer.WriteAsync( - new OutboundItem(CurrentGeneration() + 1, new RelayToken(fragment, Last: false)), - cancellationToken); - } - - /// - public ValueTask CompleteAsync(CancellationToken cancellationToken = default) - { - // The vendor closes a reply on last: true, and the sample uses an empty final token when - // the stream ended with no trailing text. - return _outbound.Writer.WriteAsync( - new OutboundItem(CurrentGeneration() + 1, new RelayToken(string.Empty, Last: true)), - cancellationToken); - } - - /// - public ValueTask StopAsync(CancellationToken cancellationToken = default) - { - // Raised before the drain, and the write loop's own gate reads the same field. The relay - // already stopped its own audio. Conversation Relay carries no clear frame, so anything - // still queued here is the only audio left that could start it playing again, and this - // queue is the only thing this process still controls. - _ = Interlocked.Increment(ref _replyGeneration); - - while (_outbound.Reader.TryRead(out _)) - { - // Drained, never sent: the barge-in above already cut off everything still queued. - } - - return ValueTask.CompletedTask; - } - - /// - /// - /// - /// This stream yields nothing today, and that is deliberate rather than a defect. The - /// read loop still drives DispatchAsync directly, so every inbound frame of this conversation - /// already reaches the arbiter by that path; rerouting it through this stream is a later change - /// that no current caller needs. Until then this half of the channel exists so the shape is - /// honest — one object really does fill both slots — and it completes when the connection does, - /// so an await foreach over it ends with the conversation rather than at once. A consumer that - /// cancels its own read gets instead, which is the rule - /// sets for every implementation of this port. - /// - /// - /// One consumer for the life of the port, and the guard is here rather than inside the iterator - /// below: an iterator's body does not run until something enumerates it, so a second call would - /// otherwise hand back a stream that throws late, or never at all if nobody enumerates it. - /// - /// - public IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default) - { - return Interlocked.CompareExchange(ref _listening, 1, 0) != 0 - ? throw new InvalidOperationException("This relay connection is already being read.") - : ListenCoreAsync(cancellationToken); - } - /// - /// - /// Nothing to release here, and disposing a port must not tear this connection down. The - /// channel, the linked cancellation source, and the socket all belong to RunAsync, which - /// releases them in its own teardown once the last turn has stopped writing — and for this - /// vendor the conversation ends when that method returns, not when whoever holds the - /// lets go of it. - /// - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { - return ValueTask.CompletedTask; - } - - /// Yields nothing, and completes when this connection ends. - /// Ends the stream early, ahead of the connection itself. - /// An empty stream, deliberately. 's remarks say why. - private async IAsyncEnumerable ListenCoreAsync( - [EnumeratorCancellation] CancellationToken cancellationToken) - { - // Linked, so the caller's own token ends the stream too, and built from the captured - // connection token rather than from _cancellation.Token for the reason that field records. - using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(_connectionToken, cancellationToken); - - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Which token fired is the whole difference, and IConversationInputPort.ListenAsync settles - // it: the connection ending is the ordinary end of the conversation and completes this stream, - // while the consumer's own cancellation throws, as any cancelled await does. Rethrown - // through the caller's token rather than by letting the linked one out, so the exception - // names the token the caller actually cancelled. Both fired at once counts as - // cancellation: the consumer asked to stop, and it gets the answer it asked for. - cancellationToken.ThrowIfCancellationRequested(); - } - - yield break; + await _input.DisposeAsync().ConfigureAwait(false); + await _output.DisposeAsync().ConfigureAwait(false); } - /// Reads the newest reply generation a barge-in has cut off, or 0 when none has. - /// The counter, read the same way the write loop's own gate reads it. - private long CurrentGeneration() + private Task AcceptAsync(object frame) { - return Interlocked.Read(ref _replyGeneration); + return _input.AcceptAsync((RelayFrame)frame); } } - - /// The relay broke the contract, and the socket must close with a reason. - /// The close status the vendor should see. - /// Why the endpoint refused. - internal sealed class RelayProtocolException(WebSocketCloseStatus status, string message) - : Exception(message) - { - /// Gets the status the socket closes with. - public WebSocketCloseStatus Status { get; } = status; - } - - /// One item queued for the write loop, carrying the reply generation it was written under. - /// - /// The reply generation was queued under, or for a - /// frame no reply owns — nothing writes one of those yet, but the write loop's gate only applies - /// when a generation is present, so a future frame outside the reply lifecycle (a close handoff, - /// for one) can opt out by carrying none. This type never reaches : - /// only is serialized, so nothing here changes the wire. - /// - /// The frame to serialize and send: a today. - internal readonly record struct OutboundItem(long? Generation, object Frame); } diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs index c6ca5676..d84ff430 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationAdapter.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.AspNetCore.Conversation; +using AgentCore.AspNetCore.Voice; using Microsoft.AspNetCore.Http; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay @@ -24,15 +24,6 @@ public sealed class TelnyxRelayConversationAdapter : IConversationTransportAdapt /// The one providers.conversation.kind value this vendor answers to. public const string TelnyxRelayKind = "telnyx-relay"; - /// The longest delay Task.Delay, CancelAfter, and Task.WaitAsync all accept. - /// - /// One millisecond short of — about 49.7 days — confirmed on net10 - /// for all three. The schema caps the document at 4294967 whole seconds for the same reason; - /// this check stays because the schema cannot express the ceiling without duplicating the - /// runtime constant. - /// - private static readonly TimeSpan MaximumBoundedDelay = TimeSpan.FromMilliseconds(uint.MaxValue - 1); - /// public string Kind => TelnyxRelayKind; @@ -90,6 +81,8 @@ internal static TelnyxRelayOptions BuildOptions(ConversationProviderConfiguratio options.CloseTimeout = ToTimeSpan("closeTimeoutSeconds", close); } + options.Voice = VoiceOptionsBinder.Build(configuration); + return options; } @@ -110,12 +103,12 @@ private static TimeSpan ToTimeSpan(string field, int seconds) } TimeSpan value = TimeSpan.FromSeconds(seconds); - return value <= MaximumBoundedDelay + return value <= BoundedTimerLimits.MaximumBoundedDelay ? value : throw Fail( field, $"is {seconds} seconds, which Task.Delay, CancelAfter, and Task.WaitAsync all " - + $"refuse at run time. The longest a timer can hold is {MaximumBoundedDelay}."); + + $"refuse at run time. The longest a timer can hold is {BoundedTimerLimits.MaximumBoundedDelay}."); } /// Builds the one failure every check here raises, pointed at the field. diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs index ae5e047f..b4bcee8a 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayConversationChannelFactory.cs @@ -1,34 +1,12 @@ -using AgentCore.Application.Conversation; -using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Voice; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay { - /// Opens a Telnyx relay channel, where one connection is both halves. - /// - /// - /// Which of the two shapes this is, and why. A relay connection is born from an accepted - /// WebSocket rather than from a caller, so there is no moment at which something above asks this - /// factory to go and open a conversation: by the time a channel could exist, the socket is already up and - /// the connection already owns it. The other shape on offer was to reach the connection through - /// , and that context carries a conversation id and the host's own - /// per-conversation values and no vendor field at all — which is exactly what makes D8 hold, and exactly - /// why it cannot smuggle a connection through. So the entry point stays - /// , - /// app.MapCall() is untouched, and this factory is built with the connection it hands out. - /// - /// - /// Nothing wires it into the container yet, and nothing can even construct it yet: the connection - /// it takes has a private constructor and - /// keeps the only instance to itself. That is deliberate, not an oversight. The caller that will - /// need a factory is the split adapter — a recognizer on one side and a synthesizer on the other — - /// which does open its channel on request; whichever of the two arrives first, that adapter or a - /// reason for this connection to hand itself out, is the change that gives this type its first - /// caller. It exists now so that adapter meets an interface with a bundled implementation already - /// behind it, and so the bundled case is written down as the ordinary one rather than the exception. - /// - /// - /// The connection that accepted this conversation's socket, and that is both halves of it. - internal sealed class TelnyxRelayConversationChannelFactory(TelnyxRelayConnection connection) : IConversationChannelFactory + /// Opens a Telnyx relay channel over the two halves of one connection. + /// The caller half of the connection that accepted this conversation's socket. + /// The reply half of the same connection. + internal sealed class TelnyxRelayConversationChannelFactory(TelnyxRelayInput input, TelnyxRelayOutput output) + : IConversationChannelFactory { /// /// @@ -41,8 +19,7 @@ public ValueTask OpenAsync( ConversationChannelContext context, CancellationToken cancellationToken = default) { - // The same object in both slots. Nothing above this line can tell, and nothing may ask. - return ValueTask.FromResult(new ConversationChannel(connection, connection)); + return ValueTask.FromResult(new ConversationChannel(input, output)); } } } diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs index a041ac99..e9362b2b 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayEndpoints.cs @@ -9,53 +9,16 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay /// /// Maps the Telnyx Conversation Relay socket onto the turn loop. /// - /// - /// - /// This is the second inbound adapter onto , and - /// the first one that carries a conversation. The /v1/responses endpoint is the other. Both - /// read the same contract, so D8 holds and the core never learns a vendor frame schema. - /// - /// - /// Nothing here decides whether this transport is in use. The composition root reads - /// providers.conversation while the host starts, picks the one transport it names, and asks it for a - /// handler through ; - /// - /// owns only the route string. This type maps whatever it is handed, and it is - /// so that is the only thing that - /// hands it anything, apart from the test host, through InternalsVisibleTo: a host names its - /// route once and changes vendors in the document. - /// - /// - /// The host owns the WebSocket middleware, and its defaults suit a browser rather than a conversation. - /// Conversation app.UseWebSockets with a KeepAliveInterval and a KeepAliveTimeout of - /// about 20 seconds. The shipped default is two minutes with no timeout, which lets a dead conversation - /// hold a session for two minutes. - /// - /// internal static class TelnyxRelayEndpointRouteBuilderExtensions { /// The route the test host maps this endpoint on. - /// - /// It is no longer a fallback: MapCall always supplies a pattern, so no production path - /// reaches this value. It is kept because the relay suite maps its host through - /// directly and needs one route string both sides agree on. - /// - public const string DefaultPattern = "/v1/{" + Conversation.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter + "}/telnyx/relay"; + public const string DefaultPattern = "/v1/{" + AgentCore.AspNetCore.Voice.ConversationEndpointRouteBuilderExtensions.EntryRouteParameter + "}/telnyx/relay"; /// Maps the socket on one route, with the limits the host chose. /// The route builder of the host. /// The route to answer on. It must carry the {entry} parameter. /// What the endpoint may do, and for how long. /// The mapped endpoint, so a host adds its own conventions. - /// - /// The options are not checked here. is what - /// builds them out of providers.conversation, and it refuses a value Task.Delay, - /// CancelAfter, or Task.WaitAsync would reject before that value is ever written - /// into a — with a - /// naming the field of - /// the document rather than a C# property. Checking again here would only repeat that work in - /// the wrong vocabulary. - /// public static IEndpointConventionBuilder MapTelnyxRelay( this IEndpointRouteBuilder endpoints, string pattern, diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs new file mode 100644 index 00000000..7a854628 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayInput.cs @@ -0,0 +1,145 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using AgentCore.AspNetCore.Voice; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +{ + /// + /// The caller half of one relay socket: turns each inbound frame into a . + /// + /// The logger of the connection. + /// Reads the id of the conversation for a log line, or a placeholder before it started. + internal sealed class TelnyxRelayInput(ILogger logger, Func conversationId) + : IConversationInputPort + { + private readonly Channel<(ConversationInput Input, TaskCompletionSource Handled)> _inputs = + Channel.CreateUnbounded<(ConversationInput Input, TaskCompletionSource Handled)>( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + + private int _listening; + + private bool _loggedMalformedInterrupt; + + private bool _sawSetup; + + /// Reads one frame the relay sent, in the order it sent them. + /// The frame the reader parsed. + /// + /// A task that completes once the consumer has handled the event the frame became, or at once when + /// it became none. The read loop awaits it, so a close frame cannot overtake the setup before it. + /// + public Task AcceptAsync(RelayFrame frame) + { + switch (frame) + { + case RelayFrame.Setup setup: + _sawSetup = true; + return PublishAsync(new ConversationInput.Started(setup.ConversationSessionId)); + + case RelayFrame.Prompt prompt: + return PublishAsync(new ConversationInput.Utterance(prompt.VoicePrompt, prompt.Lang, prompt.Last)); + + case RelayFrame.Interrupt interrupt: + return AcceptInterruptAsync(interrupt); + + case RelayFrame.Dtmf dtmf: + // Never the digit itself. A keypad carries card numbers, PINs, and dates of birth, + // and the house rule in Log.cs is that no line carries what the caller said. + TelnyxRelayLog.DtmfReceived(logger); + return PublishAsync(new ConversationInput.Keypress(dtmf.Digit)); + + case RelayFrame.Error error: + // The vendor refused a frame this endpoint sent. That is our defect. + TelnyxRelayLog.FrameRefused(logger, conversationId(), error.Description); + return Task.CompletedTask; + + default: + // The reader hands back only the frames this build models; see TelnyxRelayFrameReader.TryRead. + return Task.CompletedTask; + } + } + + /// Ends the stream: the socket is gone, so the conversation is over. + public void Complete() + { + _ = _inputs.Writer.TryComplete(); + } + + /// + public IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default) + { + return Interlocked.CompareExchange(ref _listening, 1, 0) != 0 + ? throw new InvalidOperationException("This relay connection is already being read.") + : ListenCoreAsync(cancellationToken); + } + + /// + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + private async IAsyncEnumerable ListenCoreAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + try + { + await foreach ((ConversationInput input, TaskCompletionSource handled) in _inputs.Reader + .ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) + { + try + { + yield return input; + } + finally + { + // The consumer asked for the next event, or stopped reading: either way it is done with this one. + handled.TrySetResult(); + } + } + } + finally + { + // Nothing reads what is left, so release every read-loop wait still parked on it. + _ = _inputs.Writer.TryComplete(); + + while (_inputs.Reader.TryRead(out (ConversationInput Input, TaskCompletionSource Handled) orphan)) + { + orphan.Handled.TrySetResult(); + } + } + } + + private Task AcceptInterruptAsync(RelayFrame.Interrupt interrupt) + { + if (!_sawSetup) + { + return Task.CompletedTask; + } + + if (interrupt.UtteranceUntilInterrupt is null || interrupt.DurationUntilInterruptMs < 0) + { + if (!_loggedMalformedInterrupt) + { + _loggedMalformedInterrupt = true; + TelnyxRelayLog.MalformedInterruptFrame(logger, conversationId()); + } + + return Task.CompletedTask; + } + + return PublishAsync(new ConversationInput.Barge( + interrupt.UtteranceUntilInterrupt, + TimeSpan.FromMilliseconds(interrupt.DurationUntilInterruptMs))); + } + + private Task PublishAsync(ConversationInput input) + { + TaskCompletionSource handled = new(TaskCreationOptions.RunContinuationsAsynchronously); + + return _inputs.Writer.TryWrite((input, handled)) ? handled.Task : Task.CompletedTask; + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs index b1d3cd8a..e6e8b990 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayLog.cs @@ -5,17 +5,6 @@ namespace AgentCore.AspNetCore.Vendors.TelnyxRelay /// /// Every line the relay connection writes. /// - /// - /// - /// The methods are source-generated by , so a line that nobody - /// listens to costs one level check and allocates nothing. CA1848 asks for exactly this shape - /// rather than a direct conversation on . - /// - /// - /// No line here carries what the caller said or pressed. The audit chain of D23 is the record of a - /// conversation, and a log is not; a keypad in particular carries card numbers, PINs, and dates of birth. - /// - /// internal static partial class TelnyxRelayLog { /// Section 3.1. An unmodelled frame type, logged once for the conversation. @@ -46,54 +35,20 @@ internal static partial class TelnyxRelayLog Message = "the relay refused a frame on conversation {ConversationId}: {Description}")] public static partial void FrameRefused(ILogger logger, string conversationId, string description); - /// A prompt arrived before any setup frame, logged once for the conversation. - /// The logger of the connection. - [LoggerMessage( - EventId = 4, - Level = LogLevel.Warning, - Message = "the relay sent a prompt before any setup frame. Every prompt before setup is dropped.")] - public static partial void PromptBeforeSetup(ILogger logger); - /// The read loop ended in a fault rather than a clean close. /// The logger of the connection. /// The id of the conversation, or a placeholder before setup. /// The cause. - /// - /// states the house rule: on a voice conversation, silence is - /// the failure, so this line makes sure a read loop fault is never silent. It fires for anything - /// the read loop throws other than the two causes that have their own, quieter case: a - /// logs through at - /// Warning instead, because it is a protocol violation this endpoint already refused and closed - /// on, not an unhandled defect; and a dropped socket with no close frame logs through - /// at Information. Everything else reaching this line - /// is this endpoint's own bug. - /// [LoggerMessage( EventId = 5, Level = LogLevel.Error, Message = "the relay read loop of conversation {ConversationId} ended in a fault.")] public static partial void ReadLoopFaulted(ILogger logger, string conversationId, Exception exception); - /// A turn faulted rather than finishing or being cancelled by the connection itself. - /// The logger of the connection. - /// The id of the conversation. - /// The cause. - [LoggerMessage( - EventId = 6, - Level = LogLevel.Error, - Message = "a turn of conversation {ConversationId} faulted.")] - public static partial void TurnFaulted(ILogger logger, string conversationId, Exception exception); - /// The write loop ended in a fault rather than a clean close. /// The logger of the connection. /// The id of the conversation, or a placeholder before setup. /// The cause. - /// - /// The write loop is the only task that touches the socket, so its fault — typically a - /// the relay caused, or an - /// racing teardown — is the most useful of the three to - /// see, and the one round 1 left unobserved. - /// [LoggerMessage( EventId = 7, Level = LogLevel.Error, @@ -104,13 +59,6 @@ internal static partial class TelnyxRelayLog /// The logger of the connection. /// The id of the conversation. /// Which task did not stop in time, for example "the last turn". - /// - /// Cancellation reaches a well-behaved task, but this line is not proof of one that ignored its - /// token — a task that honours cancellation yet takes longer than CloseTimeout to unwind, - /// a slow completion step or a slow sink, reaches it the same way. Left unbounded, either case - /// would otherwise wedge teardown forever: the session would stay in the store, and the Kestrel - /// request behind it would never complete. - /// [LoggerMessage( EventId = 8, Level = LogLevel.Error, @@ -121,12 +69,6 @@ internal static partial class TelnyxRelayLog /// The logger of the connection. /// The id of the conversation, or a placeholder before setup. /// The cause. - /// - /// Logged and swallowed rather than left to propagate, because a throw here must not skip the - /// write-loop observation or the session close that follow it in RunAsync. A close that - /// never runs leaves the conversation to wait out its idle timeout, and nothing waits for the words its - /// last turn still owed store 1. - /// [LoggerMessage( EventId = 9, Level = LogLevel.Error, @@ -137,72 +79,24 @@ internal static partial class TelnyxRelayLog /// The logger of the connection. /// The id of the conversation, or a placeholder before setup. /// The cause. - /// - /// Logged and swallowed rather than left to propagate, because Cancel() runs with - /// throwOnFirstException: false: every callback still ran and both loops are already - /// cancelled by the time this fires, so the only thing a throw here would still cost is the - /// rest of teardown — the write-loop observation, the close, and the session removal. - /// [LoggerMessage( EventId = 10, Level = LogLevel.Error, Message = "cancelling the connection token of conversation {ConversationId} faulted.")] public static partial void CancellationFaulted(ILogger logger, string conversationId, Exception exception); - /// A third final prompt arrived while one turn ran, logged once for the conversation. - /// The logger of the connection. - /// The id of the conversation. - /// - /// Item 6a of section 11 holds one pending prompt so a caller's second sentence is not lost. - /// A third would queue a prompt behind a question the caller has already moved past, so it is - /// dropped, and this line says so without repeating the words dropped. - /// - [LoggerMessage( - EventId = 11, - Level = LogLevel.Information, - Message = "a further prompt arrived on conversation {ConversationId} while one turn was already held. It is dropped.")] - public static partial void PendingPromptDropped(ILogger logger, string conversationId); - - /// The relay reported a barge-in. Never the words the caller said. - /// The logger of the connection. - /// The id of the conversation. - [LoggerMessage( - EventId = 12, - Level = LogLevel.Information, - Message = "the relay reported a barge-in on conversation {ConversationId}.")] - public static partial void InterruptReceived(ILogger logger, string conversationId); - /// A malformed interrupt frame was refused. Section 7.1: a bad frame must not drop the conversation. /// The logger of the connection. /// The id of the conversation. - /// - /// Fires when utteranceUntilInterrupt is missing or durationUntilInterruptMs is - /// negative, logged once for the conversation the same way an unmodelled frame type is. - /// [LoggerMessage( EventId = 13, Level = LogLevel.Warning, Message = "the relay sent a malformed interrupt frame on conversation {ConversationId}. It is dropped.")] public static partial void MalformedInterruptFrame(ILogger logger, string conversationId); - /// A second final prompt arrived while one turn ran, and is held for when it ends. - /// The logger of the connection. - /// The id of the conversation. - [LoggerMessage( - EventId = 14, - Level = LogLevel.Information, - Message = "a further prompt arrived on conversation {ConversationId} while one turn ran. It is held.")] - public static partial void PromptHeld(ILogger logger, string conversationId); - /// The relay dropped the socket with no close frame. Task 7: an ordinary end of a conversation. /// The logger of the connection. /// The id of the conversation, or a placeholder before setup. - /// - /// Fires instead of for exactly one cause: a - /// whose code is - /// . The vendor - /// never reconnects, so this is a finished conversation, not a defect this endpoint owns. - /// [LoggerMessage( EventId = 15, Level = LogLevel.Information, @@ -216,14 +110,6 @@ internal static partial class TelnyxRelayLog /// The fixed, vendor-free message carries — never the /// bytes the relay actually sent. /// - /// - /// Malformed JSON and an oversized frame both reach here. Neither is this endpoint's own - /// defect — the relay broke the contract, this endpoint already refused the frame, and - /// DetermineCloseStatus already carries the right close status from the same exception — - /// so this logs at Warning, the same level , - /// , and already use for a bad - /// frame, not at the Error level uses for this endpoint's own bug. - /// [LoggerMessage( EventId = 16, Level = LogLevel.Warning, @@ -233,12 +119,6 @@ internal static partial class TelnyxRelayLog /// No inbound frame arrived within IdleTimeout. Task 8: an ordinary end of a conversation. /// The logger of the connection. /// The id of the conversation, or a placeholder before setup. - /// - /// The vendor never reconnects, so a silent socket is a conversation that is already over, not a fault - /// this endpoint owns. This fires only when the idle deadline itself is the cause: the read - /// loop checks that the connection's own teardown token has not also fired before it logs this - /// line, so a socket closed for some other reason never gets this one instead. - /// [LoggerMessage( EventId = 17, Level = LogLevel.Information, @@ -249,15 +129,6 @@ internal static partial class TelnyxRelayLog /// The logger of the connection. /// The type value whose body was refused. /// The id of the conversation, or a placeholder before setup. - /// - /// Section 7.1 gives a vendor that adds a frame and a vendor that changes one the same rule: - /// neither may drop a conversation. A decimal durationUntilInterruptMs and a non-string value in - /// customParameters both reach here. This is the vendor's defect and not this endpoint's, - /// so it logs at Warning, beside and - /// , and never at the Error level a bug of ours gets. The - /// field that would not bind is not named: a value the vendor sent may carry what the caller - /// said. - /// [LoggerMessage( EventId = 18, Level = LogLevel.Warning, @@ -265,42 +136,6 @@ internal static partial class TelnyxRelayLog + "read. It is dropped, and the conversation continues.")] public static partial void FrameBodyRefused(ILogger logger, string frameType, string conversationId); - /// A second setup frame arrived on one socket, logged once for the conversation. - /// The logger of the connection. - /// The id of the conversation the first setup frame started. - /// - /// One socket carries one conversation, and the vendor sends one setup. A second one is the - /// vendor's defect, and section 7.1 forbids dropping a conversation over one, so the frame replaces the - /// session rather than refusing the socket. The first session is closed as it goes, because - /// teardown only ever closes the session the connection currently holds — so this is the last - /// moment anything waits for the words the replaced conversation still owed store 1. - /// - [LoggerMessage( - EventId = 19, - Level = LogLevel.Warning, - Message = "the relay sent a second setup frame on conversation {ConversationId}. The first session is " - + "released and the new one replaces it.")] - public static partial void SecondSetupFrame(ILogger logger, string conversationId); - - /// Closing the audit chain of a conversation faulted, so that call has no conversation.ended event. - /// The logger of the connection. - /// The id of the conversation whose chain did not close. - /// The cause. - /// - /// This is worth an Error line and not a quieter one. §9 makes the chain of D23 the only - /// long-term record of a conversation, and §11 item 6 says every chain ends in conversation.ended, so a - /// chain that stops without one is a permanent gap in that record rather than a passing - /// inconvenience. Logged and swallowed all the same: §7.1 forbids teardown throwing out of the - /// request handler, and the session close that follows it in RunAsync must still run — a - /// close that never runs leaves the conversation to wait out its idle timeout with its last words never - /// waited for. - /// - [LoggerMessage( - EventId = 20, - Level = LogLevel.Error, - Message = "the audit chain of conversation {ConversationId} could not be closed, so it has no conversation.ended event.")] - public static partial void ConversationEndFaulted(ILogger logger, string conversationId, Exception exception); - /// The session of a conversation could not be closed at the end of that conversation. /// The logger. /// The id of the conversation. @@ -310,15 +145,5 @@ internal static partial class TelnyxRelayLog Level = LogLevel.Error, Message = "the session of conversation {ConversationId} could not be closed, so its last words may never reach store 1 and it waits out the idle timeout.")] public static partial void ConversationCloseFaulted(ILogger logger, string conversationId, Exception exception); - - /// The store could not be told that a conversation is still being had. - /// The logger. - /// The id of the conversation. - /// The cause. - [LoggerMessage( - EventId = 23, - Level = LogLevel.Warning, - Message = "the store could not be read for conversation {ConversationId}, so that call may be dropped by the idle sweep while it is still running.")] - public static partial void SessionTouchFaulted(ILogger logger, string conversationId, Exception exception); } } diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs index 7927d71e..63240a0a 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOptions.cs @@ -1,45 +1,22 @@ +using AgentCore.AspNetCore.Voice; + namespace AgentCore.AspNetCore.Vendors.TelnyxRelay { /// /// What the relay endpoint may do, and for how long. /// - /// - /// Every default here is chosen for a phone conversation. A conversation is short, it is quiet in bytes, and a - /// dead one must be noticed in seconds rather than minutes. - /// internal sealed class TelnyxRelayOptions { /// Gets or sets the largest inbound frame the endpoint accepts, in bytes. - /// - /// The WebSocket middleware turns the request timeout off once it accepts, so nothing else - /// bounds a frame. A real setup frame is under 2 KB, and 64 KB leaves room for custom - /// parameters without letting one message exhaust the host. - /// public int MaxFrameBytes { get; set; } = 64 * 1024; /// Gets or sets how long teardown gives a stuck task before it moves on. - /// - /// Every wait that could otherwise wedge a connection shares this one bound: how long the last - /// turn of a conversation may take to unwind once teardown cancels it, how long the close handshake may - /// take before the socket is aborted, and how long the words of a conversation may take to reach store 1 - /// before its session is dropped. A telephony conversation that already dropped never answers a close - /// frame, a chat client is not guaranteed to honour cancellation promptly, and a transcript - /// store talks to a database, so each wait needs a bound or any one of them could wedge the - /// connection forever. Because they run one after another rather than together, one wedged - /// connection can spend this value several times over — longer than Kestrel's own five-second - /// default shutdown timeout. That trade is intentional: a slow, bounded teardown beats one with - /// no bound at all. - /// public TimeSpan CloseTimeout { get; set; } = TimeSpan.FromSeconds(5); /// Gets or sets how long the endpoint waits with no inbound frame before it ends the conversation. - /// - /// The vendor never reconnects, so a silent socket is a conversation that is already over. The clock - /// is a , resolved from the request's own service provider rather - /// than a property of this class: - /// when the host bound one, otherwise . A test that wants a - /// deterministic idle deadline binds that same property to a fake clock. - /// public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// Gets or sets the away prompt and the per-tool filler this conversation's voice loop runs. + public VoiceOptions Voice { get; set; } = VoiceOptions.Default; } } diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs new file mode 100644 index 00000000..e2613741 --- /dev/null +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelayOutput.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using System.Threading.Channels; +using AgentCore.AspNetCore.Voice; + +namespace AgentCore.AspNetCore.Vendors.TelnyxRelay +{ + /// + /// The reply half of one relay socket: queues text frames for the write loop, and drops every + /// frame a barge-in has cut off. + /// + internal sealed class TelnyxRelayOutput : IConversationOutputPort + { + // Bounded, so a slow relay slows the turn loop instead of growing a queue without a bound. + private readonly Channel _outbound = Channel.CreateBounded(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + }); + + private long _replyGeneration; + + // The generation of the reply begun last, fixed when it began. Read and written under _gate. + private long _openGeneration; + + // Whether a fragment was queued since the last close or stop. Read and written under _gate. + private bool _replyOpen; + + private readonly Lock _gate = new(); + + /// Gets what the write loop reads. + public ChannelReader Reader => _outbound.Reader; + + /// Gets the lock cuts a reply off under. + public Lock SendGate => _gate; + + /// Ends the queue: the write loop ends once it has sent what is left, and every later write is dropped. + /// A write already waiting for room in the full queue returns at once, rather than wait for a write loop that is gone. + public void Complete() + { + _ = _outbound.Writer.TryComplete(); + } + + /// Writes one queued item as the frame to send, or drops it. + /// What a reply queued, and the generation it was queued under. + /// The write loop's own writer, already reset for this frame. + /// + /// when a whole frame was written, and to drop + /// without sending anything. + /// + public bool Encode(OutboundItem item, Utf8JsonWriter writer) + { + if (item.Generation is { } generation && Interlocked.Read(ref _replyGeneration) >= generation) + { + return false; + } + + JsonSerializer.Serialize(writer, item.Frame, item.Frame.GetType(), TelnyxRelayJson.Options); + + return true; + } + + /// + public void BeginReply() + { + lock (_gate) + { + _openGeneration = CurrentGeneration() + 1; + _replyOpen = false; + } + } + + /// + public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default) + { + long generation; + lock (_gate) + { + generation = _openGeneration; + if (generation <= CurrentGeneration()) + { + return ValueTask.CompletedTask; + } + + _replyOpen = true; + } + + return WriteAsync(new OutboundItem(generation, new RelayToken(fragment, Last: false)), cancellationToken); + } + + /// + public ValueTask CompleteAsync(CancellationToken cancellationToken = default) + { + // A reply a barge-in stopped has nothing left to close: the speech behind it still flushes as + // it tears down, and that closing token would reach the vendor after the interrupt. + long generation; + lock (_gate) + { + if (!_replyOpen) + { + return ValueTask.CompletedTask; + } + + _replyOpen = false; + generation = _openGeneration; + } + + // The vendor closes a reply on last: true, and the sample uses an empty final token when + // the stream ended with no trailing text. + return WriteAsync(new OutboundItem(generation, new RelayToken(string.Empty, Last: true)), cancellationToken); + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + lock (_gate) + { + _replyOpen = false; + _ = Interlocked.Increment(ref _replyGeneration); + } + + while (_outbound.Reader.TryRead(out _)) + { + // Drained, never sent: the barge-in above already cut off everything still queued. + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + private static async ValueTask DropWhenCompletedAsync(ValueTask write) + { + try + { + await write.ConfigureAwait(false); + } + catch (ChannelClosedException) + { + // The socket is gone: nothing more can be sent, and nothing is waiting to hear it. + } + } + + private ValueTask WriteAsync(OutboundItem item, CancellationToken cancellationToken) + { + ValueTask write = _outbound.Writer.WriteAsync(item, cancellationToken); + return write.IsCompletedSuccessfully ? write : DropWhenCompletedAsync(write); + } + + /// Reads the newest reply generation a barge-in has cut off, or 0 when none has. + /// The counter, read the same way the write loop's own gate reads it. + private long CurrentGeneration() + { + return Interlocked.Read(ref _replyGeneration); + } + } +} diff --git a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs index 76a03f81..65282c6b 100644 --- a/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs +++ b/src/AgentCore.AspNetCore/Vendors/TelnyxRelay/TelnyxRelaySpeechAdapter.cs @@ -1,27 +1,10 @@ -using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Voice; namespace AgentCore.AspNetCore.Vendors.TelnyxRelay { /// /// The selection face of the one speech vendor this solution bundles. /// - /// - /// - /// D28 buys the whole speech layer — speech to text, turn detection, text to speech, and - /// interruption — inside Telnyx Conversation Relay, so this vendor is a transport rather than a - /// service the process dials. A host registers this through - /// AgentCoreOptions.UseSpeech, and a document that names it under providers.speech.stt - /// and providers.speech.tts then selects it — both roles, because it sells both. The socket - /// itself belongs to providers.conversation, which app.MapCall() reads — and because this - /// vendor bundles the pipe with the ears and the mouth, all three names must agree or the start - /// fails. - /// - /// - /// It carries a kind and nothing else, because does. A relay conversation - /// is born from a socket the host already accepted, so there is nothing here to create at selection - /// time: the frames arriving on that socket are the channel. - /// - /// public sealed class TelnyxRelaySpeechAdapter : ISpeechAdapter { /// The one kind value, under either speech role, this vendor answers to. diff --git a/src/AgentCore.AspNetCore/Voice/AgentState.cs b/src/AgentCore.AspNetCore/Voice/AgentState.cs new file mode 100644 index 00000000..a1d555af --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/AgentState.cs @@ -0,0 +1,25 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:309-324, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// What the agent is doing right now. + internal enum AgentState + { + /// The session has not started yet. + Initializing, + + /// Started, nothing running: no reply, no tool call, no queued speech. + Idle, + + /// Ready for the caller, nothing queued to say. + Listening, + + /// A model step or a tool call is running; nothing has reached the output yet. + Thinking, + + /// Forwarding words to the output port. + Speaking, + } +} diff --git a/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs b/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs new file mode 100644 index 00000000..b1913929 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/AgentStateChanged.cs @@ -0,0 +1,13 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:309-324 +// (AgentStateChangedEvent), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; raised as a plain event, +// not a session-wide pub/sub topic. + +namespace AgentCore.AspNetCore.Voice +{ + /// Raised each time 's agent state actually changes. + /// The state before the change. + /// The state after the change. + /// When the change happened. + internal sealed record AgentStateChanged(AgentState OldState, AgentState NewState, DateTimeOffset CreatedAt); +} diff --git a/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs b/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs new file mode 100644 index 00000000..74a3ba61 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/AsyncEvent.cs @@ -0,0 +1,65 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// + /// An async gate: waiters block until opens it, and closes it + /// again for the next round. The .NET stand-in for Python's asyncio.Event, which LiveKit uses + /// for its speech authorization gate and its scheduling and filler wake-ups. + /// + internal sealed class AsyncEvent + { + private readonly Lock _gate = new(); + + private TaskCompletionSource _source = NewSource(); + + /// Gets whether the event is set right now. + public bool IsSet + { + get + { + lock (_gate) + { + return _source.Task.IsCompleted; + } + } + } + + /// Opens the gate. Every waiter, current or future until the next , completes. + public void Set() + { + lock (_gate) + { + _source.TrySetResult(); + } + } + + /// Closes the gate for the next round. Waiters already released by a prior keep running. + public void Clear() + { + lock (_gate) + { + if (_source.Task.IsCompleted) + { + _source = NewSource(); + } + } + } + + /// Waits until the event is set. + /// Cancels the wait. The event's own state is untouched. + public Task WaitAsync(CancellationToken cancellationToken = default) + { + Task task; + lock (_gate) + { + task = _source.Task; + } + + return task.WaitAsync(cancellationToken); + } + + private static TaskCompletionSource NewSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs b/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs new file mode 100644 index 00000000..7c4b0324 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/BackgroundSpeeches.cs @@ -0,0 +1,49 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py +// (_background_speeches, _interrupt_background_speeches), commit +// d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// The speeches that finished speaking but still wait on their own tools (plan 2.1). + /// Not thread-safe. touches it only under the session lock. + internal sealed class BackgroundSpeeches + { + private readonly HashSet _speeches = []; + + /// Gets whether any speech is waiting on its own tools. + public bool Any => _speeches.Count > 0; + + /// Marks a speech as waiting for its tools after its words were spoken. + /// The speech. + public void Add(SpeechHandle speech) + { + _ = _speeches.Add(speech); + } + + /// Removes a speech added, once its tools finished. + /// The speech. + public void Remove(SpeechHandle speech) + { + _ = _speeches.Remove(speech); + } + + /// Interrupts every speech that allows it, or all of them with . + /// Interrupt even speeches that disallow interruptions. + /// Why. + /// The speeches interrupted. + public List InterruptAll(bool force, InterruptionSource source) + { + List interrupted = []; + foreach (SpeechHandle speech in _speeches) + { + if (force || speech.AllowInterruptions) + { + interrupted.Add(speech.Interrupt(force, source)); + } + } + + return interrupted; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs b/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs new file mode 100644 index 00000000..2c9d8bfb --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/BoundedTimerLimits.cs @@ -0,0 +1,14 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// The longest span any -backed wait in this assembly accepts. + internal static class BoundedTimerLimits + { + /// + /// One millisecond short of — about 49.7 days — confirmed on net10 for + /// Task.Delay, CancelAfter, and Task.WaitAsync alike. A document value above + /// this is refused at load, where the error still carries a pointer into the document, rather than + /// left to throw from inside a running conversation. + /// + public static readonly TimeSpan MaximumBoundedDelay = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + } +} diff --git a/src/AgentCore.AspNetCore/Conversation/ConnectionTaskObserver.cs b/src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs similarity index 78% rename from src/AgentCore.AspNetCore/Conversation/ConnectionTaskObserver.cs rename to src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs index 5b5cba38..8547b4c6 100644 --- a/src/AgentCore.AspNetCore/Conversation/ConnectionTaskObserver.cs +++ b/src/AgentCore.AspNetCore/Voice/ConnectionTaskObserver.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// Which task an observer is watching, so it logs the right line. internal enum ConnectionTaskKind @@ -6,9 +6,6 @@ internal enum ConnectionTaskKind /// The loop that reads the transport. ReadLoop, - /// The task running one turn. - Turn, - /// The loop that writes the transport. WriteLoop, @@ -17,7 +14,7 @@ internal enum ConnectionTaskKind } /// - /// Awaits the loops and turns of one connection, and never lets a fault go unobserved. + /// Awaits the loops and the session close of one connection, and never lets a fault go unobserved. /// /// Names the conversation in a log line. A function, because the id arrives mid-conversation. /// Logs that a task passed its teardown deadline. @@ -33,19 +30,14 @@ internal sealed class ConnectionTaskObserver( Action logFault, Func classify) { - /// Awaits a loop or a turn task, and never lets its fault go unobserved. - /// The read loop's task, the write loop's task, or a turn's task. + /// Awaits a loop or the session close, and never lets its fault go unobserved. + /// The read loop's task, the write loop's task, or the session close. /// Which log line names the fault, if there is one. /// /// How long to wait before giving up on , or to /// wait for it unconditionally. /// /// A task that completes once has been observed. - /// - /// A cancellation raised by the connection's own token is teardown that connection asked for - /// itself, so it stays quiet. Anything else is the silence a voice conversation must never answer with, - /// per the house rule in . - /// public async Task ObserveAsync(Task task, ConnectionTaskKind kind, TimeSpan? timeout = null) { try @@ -101,13 +93,6 @@ public async Task ObserveAsync(Task task, ConnectionTaskKind kind, TimeSpan? tim /// Runs one log conversation, and never lets it break the teardown sequence around it. /// The conversation to make, already bound to its logger and its arguments. - /// - /// aggregates and rethrows a provider's own fault, so - /// even a line whose only job is to report a defect can itself throw. Every log conversation teardown - /// makes — cancelling the token, the close, an observed fault, a teardown timeout — sits behind - /// this guard for that reason; logging exists to help diagnose a defect, and must never become a - /// second one that stops the store removal or the rest of teardown from running. - /// public static void SafeLog(Action log) { try @@ -128,7 +113,6 @@ private static string DisplayName(ConnectionTaskKind kind) return kind switch { ConnectionTaskKind.ReadLoop => "the read loop", - ConnectionTaskKind.Turn => "the last turn", ConnectionTaskKind.WriteLoop => "the write loop", ConnectionTaskKind.SessionClose => "the session close", _ => "a task", @@ -138,12 +122,6 @@ private static string DisplayName(ConnectionTaskKind kind) /// Hands a fault to logFault, whichever names. /// Which task faulted. /// The cause, or when none was available. - /// - /// Called from directly, and from the fault-only continuation it - /// attaches on a timeout. The continuation runs with nothing above it to catch a throw, so the - /// guard here is what keeps this method from ever throwing, not the - /// caller. - /// private void LogFault(ConnectionTaskKind kind, Exception? fault) { if (fault is null) @@ -162,11 +140,6 @@ private void LogFault(ConnectionTaskKind kind, Exception? fault) /// when the adapter handled itself, and /// when it did not — including when it threw. /// - /// - /// An adapter's classifier is another adapter's code running inside this connection's teardown, - /// and teardown must finish whatever that code does. A classifier that throws has handled - /// nothing, so the fault falls through to logFault exactly as an unrecognised one does. - /// private bool SafeClassify(Exception fault, ConnectionTaskKind kind, string id) { try diff --git a/src/AgentCore.Application/Conversation/ConversationChannel.cs b/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs similarity index 91% rename from src/AgentCore.Application/Conversation/ConversationChannel.cs rename to src/AgentCore.AspNetCore/Voice/ConversationChannel.cs index 9eb59ade..2c8167a9 100644 --- a/src/AgentCore.Application/Conversation/ConversationChannel.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationChannel.cs @@ -1,6 +1,4 @@ -using AgentCore.Application.Ports; - -namespace AgentCore.Application.Conversation +namespace AgentCore.AspNetCore.Voice { /// /// The two halves of one conversation, opened together and disposed together. @@ -55,10 +53,6 @@ public sealed record ConversationChannel(IConversationInputPort Input, IConversa private bool _disposed; /// Disposes both halves, and each underlying object exactly once. - /// - /// Reference equality is the test, because one object filling both slots is the ordinary case - /// and not the exception. Disposing it twice would let an adapter's own teardown run twice. - /// public async ValueTask DisposeAsync() { if (_disposed) diff --git a/src/AgentCore.Application/Conversation/ConversationChannelContext.cs b/src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs similarity index 64% rename from src/AgentCore.Application/Conversation/ConversationChannelContext.cs rename to src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs index 0b109aae..0b5467b1 100644 --- a/src/AgentCore.Application/Conversation/ConversationChannelContext.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationChannelContext.cs @@ -1,11 +1,7 @@ -namespace AgentCore.Application.Conversation +namespace AgentCore.AspNetCore.Voice { /// What one conversation needs to open its channel. /// The AgentCore conversation id, which the channel must not invent for itself. /// Per-conversation values the host attached, or . - /// - /// This carries no vendor field and no wire frame, so D8 holds: a factory learns what the conversation is - /// without the core learning what the vendor is. - /// public sealed record ConversationChannelContext(string ConversationId, IReadOnlyDictionary? CustomParameters); } diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationEndpointRouteBuilderExtensions.cs b/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs similarity index 93% rename from src/AgentCore.AspNetCore/Conversation/ConversationEndpointRouteBuilderExtensions.cs rename to src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs index 666aeca1..dd035679 100644 --- a/src/AgentCore.AspNetCore/Conversation/ConversationEndpointRouteBuilderExtensions.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationEndpointRouteBuilderExtensions.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// /// Maps the inbound conversation route, with the URL naming the entry, onto whichever transport the @@ -64,9 +64,6 @@ public static string EntryOf(HttpContext http) private static Task DispatchAsync(HttpContext http, string pattern) { - // GetService and never GetRequiredService, on purpose. A host may map this route with no - // AgentCore registration at all, and such a host must get a readable reason rather than a - // resolution failure. if (http.RequestServices.GetService() is not { } boot) { return NotRoutedAsync(http, pattern, "this host registered no AgentCore services"); @@ -79,7 +76,7 @@ private static Task DispatchAsync(HttpContext http, string pattern) string entry = EntryOf(http); - return boot.Entries.ConversationSessions.ContainsKey(entry) + return boot.Entries.Entries.Contains(entry, StringComparer.Ordinal) ? handler(http) : UnknownEntryAsync(http, EntryRegistry.UnknownEntryMessage(entry, boot.Entries.Entries)); } diff --git a/src/AgentCore.Application/Conversation/ConversationInput.cs b/src/AgentCore.AspNetCore/Voice/ConversationInput.cs similarity index 74% rename from src/AgentCore.Application/Conversation/ConversationInput.cs rename to src/AgentCore.AspNetCore/Voice/ConversationInput.cs index 11cec98f..b3218e95 100644 --- a/src/AgentCore.Application/Conversation/ConversationInput.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationInput.cs @@ -1,25 +1,20 @@ -namespace AgentCore.Application.Conversation +namespace AgentCore.AspNetCore.Voice { /// /// One thing the caller did, in the order it happened. /// - /// - /// - /// One ordered stream carries every inbound event, and the kind is a case of this hierarchy - /// rather than a separate stream for each. Section 3.2 asks for exactly that shape, and - /// RelayFrame in the Telnyx adapter already reads this way. - /// - /// - /// The constructor is , so the set is closed: nothing outside - /// this assembly can add a case, and a consumer's switch over the three below is exhaustive. - /// - /// public abstract record ConversationInput { private protected ConversationInput() { } + /// The conversation began. It comes first, before anything the caller did. + /// + /// The id the transport names this conversation by. A second one on the same stream replaces the first. + /// + public sealed record Started(string ConversationId) : ConversationInput; + /// The caller spoke. /// What the recognizer heard. /// The language it reported, for example en. diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationRouteLog.cs b/src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs similarity index 84% rename from src/AgentCore.AspNetCore/Conversation/ConversationRouteLog.cs rename to src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs index cd19fbb6..2f3b1fcb 100644 --- a/src/AgentCore.AspNetCore/Conversation/ConversationRouteLog.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationRouteLog.cs @@ -1,13 +1,8 @@ using Microsoft.Extensions.Logging; -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// The lines the conversation route writes while the host starts. - /// - /// Source-generated by , as CA1848 asks. Silently leaving a - /// route unmapped is how a deployment loses every call to a 404 with nothing to read, so each - /// reason says so once. - /// internal static partial class ConversationRouteLog { /// The host registered no conversation adapter, or loaded no document. diff --git a/src/AgentCore.AspNetCore/Conversation/ConversationSeams.cs b/src/AgentCore.AspNetCore/Voice/ConversationSeams.cs similarity index 90% rename from src/AgentCore.AspNetCore/Conversation/ConversationSeams.cs rename to src/AgentCore.AspNetCore/Voice/ConversationSeams.cs index 24e5ae0b..4f1ac14b 100644 --- a/src/AgentCore.AspNetCore/Conversation/ConversationSeams.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationSeams.cs @@ -1,6 +1,6 @@ using AgentCore.Application.Providers; -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// The names the conversation seam uses in every failure it raises. internal static class ConversationSeams diff --git a/src/AgentCore.Application/Conversation/ConversationSpeechPairing.cs b/src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs similarity index 72% rename from src/AgentCore.Application/Conversation/ConversationSpeechPairing.cs rename to src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs index ac62b72c..5864e89e 100644 --- a/src/AgentCore.Application/Conversation/ConversationSpeechPairing.cs +++ b/src/AgentCore.AspNetCore/Voice/ConversationSpeechPairing.cs @@ -1,34 +1,11 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Ports; -namespace AgentCore.Application.Conversation +namespace AgentCore.AspNetCore.Voice { /// /// Refuses a document whose conversation transport and speech vendors cannot coexist. /// - /// - /// - /// A transport whose frames already carry text is the recognizer and the synthesizer. A - /// document naming it for the pipe and something else for the ears or the mouth describes a - /// deployment that cannot exist, and the failure it would otherwise produce — a conversation that connects - /// and never transcribes — is among the most expensive kinds to diagnose. - /// - /// - /// The two roles are checked one at a time. A document that gets both wrong is wrong twice, and - /// hearing about one of them, fixing it, and starting again to hear about the other is the slow way - /// to learn that. - /// - /// - /// No vendor name appears here. is declared by the - /// adapter, so a second bundled vendor is one new adapter and no edit to this file. A guard that - /// grew an || per vendor is the design this one exists to avoid. - /// - /// - /// It runs from AddAgentCoreAsync rather than from the route extension, so a host that - /// forgets to map a route still learns its document is self-contradictory. - /// - /// public static class ConversationSpeechPairing { /// Checks that both speech roles are compatible with the selected conversation transport. diff --git a/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs b/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs new file mode 100644 index 00000000..71cfc851 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/EngineReplyStream.cs @@ -0,0 +1,302 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py (perform_llm_inference, +// _llm_inference_task: the text and function channels one generation feeds), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; the engine behind +// IConversationPort runs the model and the tools, so the steps are read off its one stream (plan 2.1). + +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// Reads one engine turn and splits it into steps at its tool contents. + internal sealed class EngineReplyStream + { + private readonly Channel _events = Channel.CreateUnbounded(); + + private readonly Lock _gate = new(); + + private readonly SpeechHandle _speechHandle; + + private readonly TurnMetrics _metrics; + + private readonly ILogger _logger; + + private volatile Exception? _fault; + + private int? _turnIndex; + + private TurnCut? _cut; + + private EngineReplyStream(IConversationPort port, SpeechHandle speechHandle, TurnMetrics metrics, ILogger logger) + { + Port = port; + _speechHandle = speechHandle; + _metrics = metrics; + _logger = logger; + } + + /// Gets the conversation the turn runs on. + public IConversationPort Port { get; } + + /// Gets a task that completes once the engine turn has ended, however it ended. It never faults. + public Task Completion { get; private set; } = Task.CompletedTask; + + /// Gets the fault the engine turn ended with, or . Read it after . + public Exception? Fault => _fault; + + /// Starts one engine turn once has ended, and reads it. + /// The conversation the turn runs on. + /// What the caller said. + /// The of the engine turn before, which must end first. + /// The speech this turn answers. No reading is taken once it is interrupted. + /// Where each step's time to first token goes. + /// Where an engine fault is reported. + /// Cancels the engine turn. Never the speech's own token. + /// The stream, already reading. + public static EngineReplyStream Start( + IConversationPort port, + string userInput, + Task previousRun, + SpeechHandle speechHandle, + TurnMetrics metrics, + ILogger logger, + CancellationToken cancellationToken) + { + EngineReplyStream stream = new(port, speechHandle, metrics, logger); + stream.Completion = Task.Run( + () => stream.RunAsync(userInput, previousRun, cancellationToken), CancellationToken.None); + return stream; + } + + /// Cuts this reply's engine turn where the caller stopped hearing it. Only the first cut counts. + /// What the caller heard. + /// + /// when the turn has started, so the engine holds this cut or an earlier one; + /// when the turn is still waiting on the one before it, and takes the cut the + /// moment it starts. + /// + public bool Cut(TurnCut cut) + { + lock (_gate) + { + if (_cut is not null) + { + return _turnIndex is not null; + } + + _cut = cut; + + // A report of what was heard can land after the next turn started. The engine then refuses the cut, + // since that turn's model may already have read this reply, and the history stays as it was. + if (_turnIndex is { } turnIndex && !Port.Cut(turnIndex, cut)) + { + VoiceConversationLog.CutRefused(_logger, turnIndex); + } + + return _turnIndex is not null; + } + } + + /// Replaces the cut this reply's engine turn already took with a later account of what was heard. + /// What the caller heard, as the transport reported it. + /// Does nothing for a turn never cut. A turn not started yet takes the recut in place of its cut when it starts. + public void Recut(TurnCut cut) + { + lock (_gate) + { + if (_cut is null) + { + return; + } + + _cut = cut; + if (_turnIndex is { } turnIndex && !Port.Recut(turnIndex, cut)) + { + VoiceConversationLog.RecutRefused(_logger, turnIndex); + } + } + } + + /// Waits until an event is ready to read or the turn has ended. + /// Cancels this wait only. + /// once the turn has ended and every event was read. + public ValueTask WaitToReadAsync(CancellationToken cancellationToken) + { + return _events.Reader.WaitToReadAsync(cancellationToken); + } + + /// Reads the next event without taking it. + /// The next event. + /// when no event is ready. + public bool TryPeek(out ReplyEvent next) + { + return _events.Reader.TryPeek(out next); + } + + /// Yields the current step's text, and stops before the step's first tool call or at the turn's end. + /// Stops the enumeration. + public async IAsyncEnumerable ReadStepTextAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + while (await _events.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (!_events.Reader.TryPeek(out ReplyEvent next) || next.Kind != ReplyEventKind.Text) + { + yield break; + } + + _ = _events.Reader.TryRead(out _); + yield return next.Value; + } + } + + /// Reads the tool events of the current round until its last call is answered. + /// Sees each tool call and tool result of the round, in stream order. + /// Cancels this wait. + /// when the round closed, when the turn ended first. + public async Task WaitForRoundAsync(Action onToolEvent, CancellationToken cancellationToken) + { + while (await _events.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (!_events.Reader.TryRead(out ReplyEvent next)) + { + continue; + } + + if (next.Kind == ReplyEventKind.RoundDone) + { + return true; + } + + if (next.Kind is ReplyEventKind.ToolCall or ReplyEventKind.ToolResult) + { + onToolEvent(next); + } + } + + return false; + } + + private async Task RunAsync(string userInput, Task previousRun, CancellationToken cancellationToken) + { + try + { + await previousRun.ConfigureAwait(false); + await using TurnRun run = await Port + .StartTurnAsync(new ChatMessage(ChatRole.User, userInput), origin: null, cancellationToken) + .ConfigureAwait(false); + + lock (_gate) + { + _turnIndex = run.TurnIndex; + + // A speech cut before its turn could start: the turn still runs, so the caller's words are kept. + if (_cut is { } pending) + { + _ = Port.Cut(run.TurnIndex, pending); + } + } + + await PumpAsync(run).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancelled by its owner, which is not a fault, as LiveKit's _on_llm_task_done skips a cancelled task. + } + catch (Exception ex) + { + _fault = ex; + VoiceConversationLog.SpeechTaskFaulted(_logger, ex); + } + finally + { + _ = _events.Writer.TryComplete(); + } + } + + private async Task PumpAsync(TurnRun run) + { + HashSet openCalls = []; + List heldText = []; + _metrics.StartStep(); + await foreach (ChatResponseUpdate update in run.Updates.ConfigureAwait(false)) + { + foreach (AIContent content in update.Contents) + { + Read(content, openCalls, heldText); + } + } + + // A call whose tool threw out of the run never gets a result, so its round never closes. The text + // after it is the fallback line, and the caller must hear it, unless a cut already ended the speech. + if (heldText.Count > 0 && !_speechHandle.IsInterrupted) + { + CloseRound(heldText); + } + } + + private void Read(AIContent content, HashSet openCalls, List heldText) + { + switch (content) + { + case TextContent { Text.Length: > 0 } text when openCalls.Count > 0: + // Text the model wrote after a tool call belongs to the step that follows the round. + heldText.Add(text.Text); + break; + + case TextContent { Text.Length: > 0 } text: + MarkFirstContent(); + Write(ReplyEventKind.Text, text.Text); + break; + + case FunctionCallContent call: + MarkFirstContent(); + _ = openCalls.Add(call.CallId); + Write(ReplyEventKind.ToolCall, call.CallId, call.Name); + break; + + case FunctionResultContent result when openCalls.Remove(result.CallId): + Write(ReplyEventKind.ToolResult, result.CallId); + if (openCalls.Count == 0) + { + CloseRound(heldText); + } + + break; + + default: + break; + } + } + + private void CloseRound(List heldText) + { + Write(ReplyEventKind.RoundDone, string.Empty); + _metrics.StartStep(); + foreach (string held in heldText) + { + MarkFirstContent(); + Write(ReplyEventKind.Text, held); + } + + heldText.Clear(); + } + + private void MarkFirstContent() + { + if (!_speechHandle.IsInterrupted) + { + _metrics.MarkFirstContent(); + } + } + + private void Write(ReplyEventKind kind, string value, string? toolName = null) + { + _ = _events.Writer.TryWrite(new ReplyEvent(kind, value, toolName)); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/FillerOptions.cs b/src/AgentCore.AspNetCore/Voice/FillerOptions.cs new file mode 100644 index 00000000..bba25e65 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/FillerOptions.cs @@ -0,0 +1,17 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:112-131 +// (RunContext.with_filler's parameters), commit d8405f132e1bd960f298190c18daf81ffc1faf45. +// Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to +// C#; a tool cannot open this itself (G5), so it is voice-options input to ToolFillerScope instead of a +// RunContext call. Per tool id, off by default — plan Q1/owner ruling. + +namespace AgentCore.AspNetCore.Voice +{ + /// One tool id's filler: what to say while its step is still running, and how often. + /// What to say. See . + /// How long the session must stay continuously idle before the first fire. + /// + /// The wall-clock cooldown after a fire before the dwell restarts. fires at most once. + /// + /// The most fires across the tool call's lifetime. means no limit. + internal sealed record FillerOptions(FillerSource Source, TimeSpan Delay, TimeSpan? Interval = null, int? MaxSteps = null); +} diff --git a/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs b/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs new file mode 100644 index 00000000..c5900f8b --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/FillerScheduler.cs @@ -0,0 +1,201 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/filler_scheduler.py:16-123 +// (_FillerScheduler), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; reset_dwell is not ported +// (its only caller, ctx.update() for async tools, is out of scope per plan section 3). + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Fires filler speech after the session has been continuously idle for a dwell, while the + /// speech this instance was created for is still running. + /// + internal sealed class FillerScheduler : IAsyncDisposable + { + private readonly VoiceSession _session; + + private readonly SpeechHandle _speechHandle; + + private readonly FillerSource _source; + + private readonly TimeSpan _delay; + + private readonly TimeSpan? _interval; + + private readonly int? _maxSteps; + + private readonly CancellationTokenSource _cancellation = new(); + + private readonly AsyncEvent _speaking = new(); + + private readonly List _created = []; + + private readonly Task _mainTask; + + private readonly Lock _gate = new(); + + private bool _closing; + + /// Where idleness, state changes, and say come from. + /// The loop stops early once this speech is interrupted. + /// What to say when the dwell elapses. + /// How long the session must stay continuously idle before the first fire. + /// The wall-clock cooldown after a fire, or to fire at most once. + /// The most fires across this instance's lifetime, or for no limit. + public FillerScheduler( + VoiceSession session, + SpeechHandle speechHandle, + FillerSource source, + TimeSpan delay, + TimeSpan? interval = null, + int? maxSteps = null) + { + if (delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delay), delay, "delay must be non-negative"); + } + + if (interval is { } value && value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(interval), interval, "interval must be non-negative when set"); + } + + _session = session; + _speechHandle = speechHandle; + _source = source; + _delay = delay; + _interval = interval; + _maxSteps = maxSteps; + _mainTask = Task.Run(RunAsync); + } + + /// Gets every speech this instance made create, or resolved + /// straight from a callable source, in fire order. + public IReadOnlyList CreatedSpeeches => _created; + + /// Stops the loop, if it has not already stopped on its own. + /// No fire starts once this is called, even one whose dwell has already elapsed. + public async Task CloseAsync() + { + lock (_gate) + { + _closing = true; + } + + if (!_mainTask.IsCompleted) + { + await TaskTeardown.CancelAndWaitAsync(_cancellation, [_mainTask]).ConfigureAwait(false); + } + } + + public ValueTask DisposeAsync() + { + return new ValueTask(CloseAsync()); + } + + private async Task RunAsync() + { + void OnAgentState(AgentStateChanged change) + { + if (change.NewState is AgentState.Speaking or AgentState.Thinking) + { + _speaking.Set(); + } + } + + void OnUserState(UserStateChanged change) + { + if (change.NewState == UserState.Speaking) + { + _speaking.Set(); + } + } + + _session.AgentStateChanged += OnAgentState; + _session.UserStateChanged += OnUserState; + + Task loop = Task.Run(() => LoopAsync(_cancellation.Token), _cancellation.Token); + try + { + await _speechHandle.WaitIfNotInterruptedAsync([loop]).ConfigureAwait(false); + } + finally + { + if (!loop.IsCompleted) + { + await TaskTeardown.CancelAndWaitAsync(_cancellation, [loop]).ConfigureAwait(false); + } + else if (loop.Exception is { } fault) + { + // WaitIfNotInterruptedAsync collects the loop's fault instead of raising it, so a + // filler that dies on its first fire would otherwise leave no trace. + VoiceConversationLog.FillerFaulted(_session.Logger, fault.GetBaseException()); + } + + _session.AgentStateChanged -= OnAgentState; + _session.UserStateChanged -= OnUserState; + } + } + + private async Task LoopAsync(CancellationToken cancellationToken) + { + while (true) + { + await _session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false); + _speaking.Clear(); + + try + { + await _speaking.WaitAsync(cancellationToken).WaitAsync(_delay, _session.Time, cancellationToken).ConfigureAwait(false); + continue; + } + catch (TimeoutException) + { + // The dwell elapsed with no interruption: fire below. + } + + if (!TryFire()) + { + return; + } + + if (_interval is not { } interval || (_maxSteps is { } max && _created.Count >= max)) + { + return; + } + + await Task.Delay(interval, _session.Time, cancellationToken).ConfigureAwait(false); + } + } + + /// when a close began first, so nothing fired. + private bool TryFire() + { + // No await between resolving the source and using its result: a state change racing this + // fire must land on the next dwell, not unwind a fire already committed to (filler_scheduler.py:82). + // The lock keeps a close that lands after the dwell from being passed by this fire, which LiveKit's + // single-threaded loop gets for free. + lock (_gate) + { + if (_closing) + { + return false; + } + + object? fired = _source.Resolve(_created.Count); + SpeechHandle? handle = fired switch + { + string text => _session.Say(text), + SpeechHandle already => already, + _ => null, + }; + + if (handle is not null) + { + _created.Add(handle); + } + + return true; + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/FillerSource.cs b/src/AgentCore.AspNetCore/Voice/FillerSource.cs new file mode 100644 index 00000000..e4c1d6c7 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/FillerSource.cs @@ -0,0 +1,38 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/filler_scheduler.py:16 +// (_FillerSource), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// What a filler says: fixed text, or a callable invoked lazily at fire time with the fire count so far. + internal readonly struct FillerSource + { + private readonly string? _text; + + private readonly Func? _factory; + + private FillerSource(string? text, Func? factory) + { + _text = text; + _factory = factory; + } + + public static implicit operator FillerSource(string text) + { + return new(text, null); + } + + /// Creates a source invoked lazily at fire time. + /// Returns a , a , or . + public static FillerSource FromCallable(Func factory) + { + return new(null, factory); + } + + /// Resolves what to fire, at the given fire count. + internal object? Resolve(int step) + { + return _factory is not null ? _factory(step) : _text; + } + } +} diff --git a/src/AgentCore.AspNetCore/Conversation/FrameOutcome.cs b/src/AgentCore.AspNetCore/Voice/FrameOutcome.cs similarity index 91% rename from src/AgentCore.AspNetCore/Conversation/FrameOutcome.cs rename to src/AgentCore.AspNetCore/Voice/FrameOutcome.cs index 88e919a7..1457a8f0 100644 --- a/src/AgentCore.AspNetCore/Conversation/FrameOutcome.cs +++ b/src/AgentCore.AspNetCore/Voice/FrameOutcome.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// What reading one inbound message produced. /// The parsed frame, or when none was read. diff --git a/src/AgentCore.AspNetCore/Conversation/FrameParser.cs b/src/AgentCore.AspNetCore/Voice/FrameParser.cs similarity index 88% rename from src/AgentCore.AspNetCore/Conversation/FrameParser.cs rename to src/AgentCore.AspNetCore/Voice/FrameParser.cs index 7fb152ca..50511947 100644 --- a/src/AgentCore.AspNetCore/Conversation/FrameParser.cs +++ b/src/AgentCore.AspNetCore/Voice/FrameParser.cs @@ -1,4 +1,4 @@ -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// Reads one reassembled inbound message into a . /// The whole message, already reassembled. diff --git a/src/AgentCore.AspNetCore/Voice/IConversationAdapter.cs b/src/AgentCore.AspNetCore/Voice/IConversationAdapter.cs new file mode 100644 index 00000000..eb9d0f41 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/IConversationAdapter.cs @@ -0,0 +1,15 @@ +using AgentCore.Application.Ports; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Names the vendor behind one providers.conversation value: who carries the conversation. + /// + public interface IConversationAdapter : IVendorAdapter + { + /// + /// Gets whether this transport's frames already carry text. + /// + bool CarriesText { get; } + } +} diff --git a/src/AgentCore.Application/Ports/IConversationChannelFactory.cs b/src/AgentCore.AspNetCore/Voice/IConversationChannelFactory.cs similarity index 87% rename from src/AgentCore.Application/Ports/IConversationChannelFactory.cs rename to src/AgentCore.AspNetCore/Voice/IConversationChannelFactory.cs index 3d98d68c..fc47a7eb 100644 --- a/src/AgentCore.Application/Ports/IConversationChannelFactory.cs +++ b/src/AgentCore.AspNetCore/Voice/IConversationChannelFactory.cs @@ -1,6 +1,4 @@ -using AgentCore.Application.Conversation; - -namespace AgentCore.Application.Ports +namespace AgentCore.AspNetCore.Voice { /// Opens the channel for one conversation. public interface IConversationChannelFactory diff --git a/src/AgentCore.AspNetCore/Voice/IConversationInputPort.cs b/src/AgentCore.AspNetCore/Voice/IConversationInputPort.cs new file mode 100644 index 00000000..1a19a0a9 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/IConversationInputPort.cs @@ -0,0 +1,17 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// + /// What the caller did, for one conversation, as one ordered stream. + /// + public interface IConversationInputPort : IAsyncDisposable + { + /// Reads what the caller does, until the conversation ends. + /// + /// Abandons the read. Cancelling it throws, and never ends the stream quietly — see the remarks. + /// + /// Every inbound event of the conversation, in order. + /// This port is already being read. + /// was cancelled. + IAsyncEnumerable ListenAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/AgentCore.AspNetCore/Voice/IConversationOutputPort.cs b/src/AgentCore.AspNetCore/Voice/IConversationOutputPort.cs new file mode 100644 index 00000000..9cd70e52 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/IConversationOutputPort.cs @@ -0,0 +1,24 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// + /// What the agent says, for one conversation. + /// + public interface IConversationOutputPort : IAsyncDisposable + { + /// Starts one reply. Called before the reply's first . + void BeginReply(); + + /// Speaks one fragment of the reply. + /// The text to speak. Never empty. + /// Stops waiting for room to send. + ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default); + + /// Closes one reply, so the synthesizer knows no more is coming. + /// Stops waiting for room to send. + ValueTask CompleteAsync(CancellationToken cancellationToken = default); + + /// Starts stopping the reply, and drops whatever has not been spoken. + /// Stops waiting on the transport. + ValueTask StopAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/AgentCore.AspNetCore/Conversation/IConversationTransportAdapter.cs b/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs similarity index 92% rename from src/AgentCore.AspNetCore/Conversation/IConversationTransportAdapter.cs rename to src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs index 9151df56..c959a609 100644 --- a/src/AgentCore.AspNetCore/Conversation/IConversationTransportAdapter.cs +++ b/src/AgentCore.AspNetCore/Voice/IConversationTransportAdapter.cs @@ -1,8 +1,7 @@ using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Ports; using Microsoft.AspNetCore.Http; -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// /// A conversation vendor this process is dialled in to, which therefore owns an inbound route. diff --git a/src/AgentCore.AspNetCore/Voice/ISpeechAdapter.cs b/src/AgentCore.AspNetCore/Voice/ISpeechAdapter.cs new file mode 100644 index 00000000..b8375aa9 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/ISpeechAdapter.cs @@ -0,0 +1,11 @@ +using AgentCore.Application.Ports; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Names the vendor behind one speech role's kind, stt or tts. + /// + public interface ISpeechAdapter : IVendorAdapter + { + } +} diff --git a/src/AgentCore.AspNetCore/Voice/InterruptionSource.cs b/src/AgentCore.AspNetCore/Voice/InterruptionSource.cs new file mode 100644 index 00000000..089ccdd9 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/InterruptionSource.cs @@ -0,0 +1,19 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/speech_handle.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// Why a speech was interrupted. The first source recorded against a handle wins. + internal enum InterruptionSource + { + /// The caller started talking over the speech. Telnyx's interrupt frame maps here. + AudioActivity, + + /// A committed user turn preempted the speech. + UserTurn, + + /// Code did it: VoiceSession.Interrupt(), a tool, or teardown. + Programmatic, + } +} diff --git a/src/AgentCore.AspNetCore/Conversation/JsonWebSocketPump.cs b/src/AgentCore.AspNetCore/Voice/JsonWebSocketPump.cs similarity index 57% rename from src/AgentCore.AspNetCore/Conversation/JsonWebSocketPump.cs rename to src/AgentCore.AspNetCore/Voice/JsonWebSocketPump.cs index e185220b..a9ba22a9 100644 --- a/src/AgentCore.AspNetCore/Conversation/JsonWebSocketPump.cs +++ b/src/AgentCore.AspNetCore/Voice/JsonWebSocketPump.cs @@ -1,29 +1,11 @@ using System.Buffers; using System.Net.WebSockets; -using System.Text.Json; -using System.Threading.Channels; -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// - /// The read loop, the write loop, and the close of one duplex JSON-over-WebSocket connection. + /// The read loop of one duplex JSON-over-WebSocket connection. is the send half. /// - /// - /// - /// Moved out of the Telnyx connection with its comments intact. Every duplex conversation adapter reads - /// one message at a time off a socket that fragments where it likes, sends from exactly one task - /// because a WebSocket supports one send at a time, and closes on a bounded deadline — and none of - /// that names a vendor. The three seams that would have are delegates: the parse, the exception - /// factory, and the log callbacks. - /// - /// - /// Nothing here decides what a message means or what a reply is worth sending. The parse belongs to - /// the adapter's own reader, and so does the gate in front of the write loop's one send: an item - /// that a barge-in already cut off is stale in the output port's vocabulary, not the transport's, - /// so only asks its caller to write the bytes and drops whatever the - /// caller declines to write. - /// - /// /// The accepted socket. The connection that built this pump owns its lifetime. /// What this pump may do, and for how long. /// The clock the idle deadline reads. @@ -47,11 +29,6 @@ internal sealed class JsonWebSocketPump( /// one — starts its own task and returns. /// /// A task that completes when the socket, the idle deadline, or teardown ends the loop. - /// - /// Returns rather than throws for every ordinary end of a conversation, including the idle deadline, so - /// a caller reading for its close status sees a fault only when - /// something actually went wrong. - /// public async Task ReadLoopAsync(FrameParser parse, Func dispatch) { byte[] rented = ArrayPool.Shared.Rent(4 * 1024); @@ -167,11 +144,6 @@ public async Task ReadLoopAsync(FrameParser parse, Func dispatch) /// once the whole message is in ; /// when the peer sent a close frame instead. /// - /// - /// A loop that parsed each receive on its own would fail on a fragment, and it would cut a - /// multi-byte character in half. Only the first fragment raced the idle deadline: the vendor - /// is, by definition, no longer silent once one fragment of a message has already arrived. - /// private async Task AssembleMessageAsync( ValueWebSocketReceiveResult first, byte[] rented, @@ -239,98 +211,6 @@ private void RefuseFrame(FrameOutcome outcome) } } - /// Sends whatever the caller queues, one item at a time, until the conversation ends. - /// Whatever the caller queues. This pump never looks inside one. - /// The queue the caller's own writers fill. - /// - /// Writes one queued item into the loop's own , and returns - /// to drop it instead. Both halves belong to the caller: the writing is - /// the adapter's wire format, and the drop is the last barge-in gate, which is expressed in the - /// output port's own vocabulary and never in this pump's. It runs immediately before the one - /// send below, which is the whole point of it running here rather than at the point an item was - /// queued. It is handed the writer rather than asked for an array so that the buffer behind it - /// can be reused for the life of the loop; it must write one complete JSON value and it must not - /// keep the writer, flush it, or hold anything the writer wrote past its own return. - /// - /// A task that completes when the queue completes, the socket closes, or teardown cancels. - public async Task WriteLoopAsync(ChannelReader reader, Func encode) - { - // One buffer and one writer for the life of the loop, rather than one array per spoken - // word. The channel is SingleReader, so this loop is the only thing that touches either. - // Every send is awaited before the next frame resets the buffer, so nothing can hand the - // same memory to two sends at once — and WrittenMemory never leaves this method, so nothing - // outside it can still be holding the previous frame's bytes when the reset happens. - ArrayBufferWriter buffer = new(256); - using Utf8JsonWriter writer = new(buffer); - - await foreach (T? item in reader.ReadAllAsync(connectionToken).ConfigureAwait(false)) - { - if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) - { - return; - } - - // Reset before encode rather than after the send: an item the gate inside encode drops - // writes nothing at all, and leaving the reset to the next iteration would then send - // the frame before it a second time. - buffer.ResetWrittenCount(); - writer.Reset(buffer); - - if (!encode(item, writer)) - { - continue; - } - - // The caller writes; this loop flushes. A Utf8JsonWriter holds the tail of a value in - // its own buffer until something asks for it, so the send below would otherwise get a - // truncated frame rather than a whole one. - await writer.FlushAsync().ConfigureAwait(false); - - await socket - .SendAsync(buffer.WrittenMemory, WebSocketMessageType.Text, endOfMessage: true, connectionToken) - .ConfigureAwait(false); - } - } - - /// Closes the socket, and never waits on the peer forever to do it. - /// The status the peer should see. - /// Why, or when the status says it all. - /// A task that completes once the socket is closed or aborted. - public async Task CloseAsync(WebSocketCloseStatus status, string? description) - { - if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) - { - return; - } - - // Sends serialize behind the WebSocket's own send mutex, so a close that overlaps a send - // stuck on relay backpressure waits on it rather than throwing — and CancellationToken.None - // would then wait forever. The connection token is already cancelled by the time this runs, - // so the close gets its own bounded token instead. - using CancellationTokenSource closeDeadline = new(); - closeDeadline.CancelAfter(options.CloseTimeout); - - try - { - // CloseOutputAsync, never CloseAsync. CloseAsync waits for the peer close frame, and a - // conversation that already dropped never sends one. - await socket - .CloseOutputAsync(status, TruncateForCloseFrame(description), closeDeadline.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (closeDeadline.IsCancellationRequested) - { - // The relay stopped reading and applied backpressure, so the send behind the close - // would never complete. Abort rather than leave the connection, the Kestrel request, - // and the store entry alive forever. - socket.Abort(); - } - catch (WebSocketException) - { - // The far end already went away. That is the end of a conversation, not a fault. - } - } - /// Waits out the losing side of the idle race, then hands its buffer back to the pool. /// The receive the idle deadline, or teardown, won the race against. /// @@ -339,27 +219,6 @@ await socket /// abandons this receive — ReadLoopAsync's own finally /// must not also return it, or the pool would see it twice. /// - /// - /// - /// Nothing here awaits inline, and nothing may: the socket is - /// about to receive a graceful close, and blocking on a receive that is still pending against - /// a socket this connection no longer owns would either wait on a peer that already stopped - /// sending or throw once the close races it. - /// - /// - /// The buffer is returned from this continuation, never from ReadLoopAsync's own - /// finally, because that finally runs the instant ReadLoopAsync returns — - /// while can still be mid-write into . A - /// probe with two real sockets over a loopback TCP pair proved exactly that: returning early - /// let a second renter receive the same array, and the first receive's own bytes then landed - /// in it once the peer actually sent something, overwriting whatever the second renter had put - /// there. is shared with every other read loop in this - /// process, including one on a completely different conversation, so that corruption is not confined - /// to this connection. Deferring the return until itself finishes - /// — successfully, faulted, or cancelled, it makes no difference — is what keeps the array out - /// of the pool for exactly as long as something can still write to it, never shorter. - /// - /// private static void ObserveAbandonedReceive(Task receiving, byte[] buffer) { _ = receiving.ContinueWith( @@ -379,19 +238,5 @@ private static void ObserveAbandonedReceive(Task re TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } - - /// Keeps a close description inside the 123-byte limit the close frame's control payload allows. - /// The description a status carries, or null. - /// The description, cut short if needed, or null. - /// - /// Every description this class ever passes is one of the two fixed, short, plain-English - /// messages the protocol faults above carry today, so a character cut is a safe - /// stand-in for a byte-accurate one — this only guards a future message that grows past the - /// limit, so never throws instead of closing. - /// - private static string? TruncateForCloseFrame(string? description) - { - return description is { Length: > 100 } ? description[..100] : description; - } } } diff --git a/src/AgentCore.AspNetCore/Conversation/JsonWebSocketPumpOptions.cs b/src/AgentCore.AspNetCore/Voice/JsonWebSocketPumpOptions.cs similarity index 97% rename from src/AgentCore.AspNetCore/Conversation/JsonWebSocketPumpOptions.cs rename to src/AgentCore.AspNetCore/Voice/JsonWebSocketPumpOptions.cs index d88686ec..2c44e90d 100644 --- a/src/AgentCore.AspNetCore/Conversation/JsonWebSocketPumpOptions.cs +++ b/src/AgentCore.AspNetCore/Voice/JsonWebSocketPumpOptions.cs @@ -1,6 +1,6 @@ using System.Net.WebSockets; -namespace AgentCore.AspNetCore.Conversation +namespace AgentCore.AspNetCore.Voice { /// What one pump may do, and for how long. /// The largest inbound message the pump accepts, in bytes. diff --git a/src/AgentCore.AspNetCore/Voice/JsonWebSocketSender.cs b/src/AgentCore.AspNetCore/Voice/JsonWebSocketSender.cs new file mode 100644 index 00000000..cf78c203 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/JsonWebSocketSender.cs @@ -0,0 +1,154 @@ +using System.Buffers; +using System.Net.WebSockets; +using System.Text.Json; +using System.Threading.Channels; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Every send of one duplex JSON-over-WebSocket connection: the write loop, and the close frame. + /// + /// The accepted socket. The connection that built this sender owns its lifetime. + /// How long the close handshake may take before the socket is aborted. + /// + /// Cancelled once, for any reason the socket is going away: the request aborted, the host is + /// stopping, or one of the connection's own tasks ended. + /// + internal sealed class JsonWebSocketSender(WebSocket socket, TimeSpan closeTimeout, CancellationToken connectionToken) + { + /// Sends whatever the caller queues, one item at a time, until the conversation ends. + /// Whatever the caller queues. This sender never looks inside one. + /// The queue the caller's own writers fill. + /// + /// Writes one queued item into the loop's own , and returns + /// to drop it instead. Both halves belong to the caller: the writing is + /// the adapter's wire format, and the drop is the last barge-in gate, which is expressed in the + /// output port's own vocabulary and never in this sender's. It runs immediately before the one + /// send below, which is the whole point of it running here rather than at the point an item was + /// queued. It is handed the writer rather than asked for an array so that the buffer behind it + /// can be reused for the life of the loop; it must write one complete JSON value and it must not + /// keep the writer, flush it, or hold anything the writer wrote past its own return. + /// + /// + /// Held from until the send has started, never across an await. A caller that + /// changes what drops under the same lock knows that, once it releases the + /// lock, no frame the change drops can still start going out. + /// + /// + /// A task that completes when the queue completes, the socket is gone, or teardown cancels. A send that + /// finds the socket gone ends the loop without a fault: the read loop and teardown report why it went. + /// + public async Task WriteLoopAsync(ChannelReader reader, Func encode, Lock sendGate) + { + // One buffer and one writer for the life of the loop, rather than one array per spoken + // word. The channel is SingleReader, so this loop is the only thing that touches either. + // Every send is awaited before the next frame resets the buffer, so nothing can hand the + // same memory to two sends at once — and WrittenMemory never leaves this method, so nothing + // outside it can still be holding the previous frame's bytes when the reset happens. + ArrayBufferWriter buffer = new(256); + using Utf8JsonWriter writer = new(buffer); + + try + { + await foreach (T? item in reader.ReadAllAsync(connectionToken).ConfigureAwait(false)) + { + if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) + { + return; + } + + ValueTask sending; + lock (sendGate) + { + // Reset before encode rather than after the send: an item the gate inside encode drops + // writes nothing at all, and leaving the reset to the next iteration would then send + // the frame before it a second time. + buffer.ResetWrittenCount(); + writer.Reset(buffer); + + if (!encode(item, writer)) + { + continue; + } + + // The caller writes; this loop flushes. A Utf8JsonWriter holds the tail of a value in + // its own buffer until something asks for it, so the send below would otherwise get a + // truncated frame rather than a whole one. The buffer is in memory, so this never waits. + writer.Flush(); + + sending = socket.SendAsync( + buffer.WrittenMemory, WebSocketMessageType.Text, endOfMessage: true, connectionToken); + } + + await sending.ConfigureAwait(false); + } + } + catch (Exception fault) when (IsSocketGone(fault)) + { + // The peer dropped, or the close already ran: nothing more can be sent, and that is not a fault + // of this loop. A dropped read or teardown already names how the conversation ended. + } + } + + /// Closes the socket, and never waits on the peer forever to do it. + /// The status the peer should see. + /// Why, or when the status says it all. + /// A task that completes once the socket is closed or aborted. + public async Task CloseAsync(WebSocketCloseStatus status, string? description) + { + if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) + { + return; + } + + // Sends serialize behind the WebSocket's own send mutex, so a close that overlaps a send + // stuck on relay backpressure waits on it rather than throwing — and CancellationToken.None + // would then wait forever. The connection token is already cancelled by the time this runs, + // so the close gets its own bounded token instead. + using CancellationTokenSource closeDeadline = new(); + closeDeadline.CancelAfter(closeTimeout); + + try + { + // CloseOutputAsync, never CloseAsync. CloseAsync waits for the peer close frame, and a + // conversation that already dropped never sends one. + await socket + .CloseOutputAsync(status, TruncateForCloseFrame(description), closeDeadline.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (closeDeadline.IsCancellationRequested) + { + // The relay stopped reading and applied backpressure, so the send behind the close + // would never complete. Abort rather than leave the connection, the Kestrel request, + // and the store entry alive forever. + socket.Abort(); + } + catch (OperationCanceledException) when (socket.State == WebSocketState.Aborted) + { + // A send the teardown cancelled aborted the socket under the close: there is nothing left to close. + } + catch (WebSocketException) + { + // The far end already went away. That is the end of a conversation, not a fault. + } + } + + /// Keeps a close description inside the 123-byte limit the close frame's control payload allows. + /// The description a status carries, or null. + /// The description, cut short if needed, or null. + private static string? TruncateForCloseFrame(string? description) + { + return description is { Length: > 100 } ? description[..100] : description; + } + + /// Tells a send that failed because the socket went away from a fault of this connection. + /// What the write loop ended with. + /// when the socket was already unusable, so the loop just ends. + private bool IsSocketGone(Exception fault) + { + return fault is WebSocketException { WebSocketErrorCode: WebSocketError.ConnectionClosedPrematurely } + || (fault is WebSocketException or OperationCanceledException + && socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/PipelineReply.cs b/src/AgentCore.AspNetCore/Voice/PipelineReply.cs new file mode 100644 index 00000000..aabd6205 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/PipelineReply.cs @@ -0,0 +1,219 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:3387-4030 +// (_pipeline_reply_task, _pipeline_reply_task_impl), commit d8405f132e1bd960f298190c18daf81ffc1faf45. +// Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; +// the engine behind IConversationPort runs the model, the tools and every step, so the steps are read off +// its stream (plan 2.1); no audio, no TTS, no chat-context writes, no realtime or handoff branch. + +namespace AgentCore.AspNetCore.Voice +{ + /// Speaks one engine turn, one step at a time: the heart of a generate_reply. + /// The speech this reply plays. + /// The session whose scheduler, output and states the reply drives. + /// The engine turn, already reading. + /// Times this reply. + /// The filler each tool id opens while its call runs (plan 2.4). Empty by default. + /// Runs once, inline on the forwarding task, when this reply hands its first text to the output. + /// How long a reply a final prompt cut mid-step waits for the transport's report. + /// Cancelled once, when the transport goes away and no report can come. + internal sealed class PipelineReply( + SpeechHandle speechHandle, + VoiceSession session, + EngineReplyStream stream, + TurnMetrics metrics, + IReadOnlyDictionary fillers, + Action onFirstText, + TimeSpan heardTextWait, + CancellationToken transportToken) + { + /// What a voice caller hears when a tool waits on an approval the conversation cannot take (plan B11). + internal const string PendingApprovalNotice = + "That action needs a human approval, which this conversation can't take."; + + /// Gets the speech this reply plays. + public SpeechHandle SpeechHandle => speechHandle; + + /// Gets what the caller heard of this reply, and the cut its engine turn takes from it. + public ReplyHearing Hearing { get; } = new(stream, session.Time, heardTextWait, transportToken); + + /// Runs every step of the reply until the engine turn ends or the speech is interrupted. + public async Task RunAsync() + { + CancellationToken cancellationToken = speechHandle.TaskCancellationToken; + while (true) + { + await speechHandle.WaitIfNotInterruptedAsync([speechHandle.WaitForScheduledAsync()]).ConfigureAwait(false); + if (speechHandle.IsInterrupted) + { + await EndInterruptedAsync(cancellationToken).ConfigureAwait(false); + return; + } + + session.SetAgentState(AgentState.Thinking); + + List authorizationTasks = [speechHandle.WaitForAuthorizationAsync(cancellationToken)]; + if (speechHandle.AllowInterruptions) + { + authorizationTasks.Add(session.WaitForUserSilenceAsync(cancellationToken)); + } + + await speechHandle.WaitIfNotInterruptedAsync(authorizationTasks).ConfigureAwait(false); + speechHandle.ClearAuthorization(); + if (speechHandle.IsInterrupted) + { + await EndInterruptedAsync(cancellationToken).ConfigureAwait(false); + return; + } + + await ForwardStepAsync(cancellationToken).ConfigureAwait(false); + bool hasTools = !speechHandle.IsInterrupted + && stream.TryPeek(out ReplyEvent next) + && next.Kind == ReplyEventKind.ToolCall; + + if (hasTools) + { + session.SetAgentState(AgentState.Thinking); + } + else if (session.AgentState == AgentState.Speaking) + { + session.SetAgentState(AgentState.Listening); + } + + speechHandle.MarkGenerationDone(); + + if (speechHandle.IsInterrupted) + { + await EndInterruptedAsync(cancellationToken).ConfigureAwait(false); + return; + } + + bool roundDone = hasTools && await WaitForToolsAsync(cancellationToken).ConfigureAwait(false); + if (speechHandle.IsInterrupted) + { + await EndInterruptedAsync(cancellationToken).ConfigureAwait(false); + return; + } + + if (!roundDone) + { + EndReply(); + return; + } + + speechHandle.IncrementNumSteps(); + session.Scheduler.ScheduleSpeech(speechHandle, SpeechPriority.Normal, force: true); + } + } + + private async Task ForwardStepAsync(CancellationToken cancellationToken) + { + Task ready = stream.WaitToReadAsync(cancellationToken).AsTask(); + await speechHandle.WaitIfNotInterruptedAsync([ready]).ConfigureAwait(false); + + // LiveKit opens a segment on the first non-empty text only, so a step with no text never flushes. + if (speechHandle.IsInterrupted + || !await ready.ConfigureAwait(false) + || !stream.TryPeek(out ReplyEvent first) + || first.Kind != ReplyEventKind.Text) + { + return; + } + + TextForwardingResult forwarded = await TextForwarding.ForwardAsync( + speechHandle, + session.Output, + stream.ReadStepTextAsync(cancellationToken), + OnFirstText, + cancellationToken).ConfigureAwait(false); + + Hearing.AddStep(forwarded); + if (forwarded.Playback != TextPlayback.Skipped) + { + metrics.MarkSpeechEnded(); + } + } + + private void OnFirstText() + { + Hearing.MarkFirstText(); + onFirstText(this); + session.SetAgentState(AgentState.Speaking); + metrics.MarkFirstSpeech(); + } + + private async Task WaitForToolsAsync(CancellationToken cancellationToken) + { + Dictionary scopes = []; + List closing = []; + + void OnToolEvent(ReplyEvent toolEvent) + { + if (toolEvent is { Kind: ReplyEventKind.ToolCall, ToolName: { } name } + && fillers.TryGetValue(name, out FillerOptions? filler)) + { + scopes[toolEvent.Value] = new ToolFillerScope(session, speechHandle, filler); + } + else if (toolEvent.Kind == ReplyEventKind.ToolResult && scopes.Remove(toolEvent.Value, out ToolFillerScope? scope)) + { + // with_filler exits as its own tool returns (events.py:150-153). A filler speech it already + // made plays on, as aclose cancels the scheduler only. + closing.Add(scope.DisposeAsync().AsTask()); + } + } + + session.Scheduler.AddBackgroundSpeech(speechHandle); + using CancellationTokenSource roundCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task round = stream.WaitForRoundAsync(OnToolEvent, roundCancellation.Token); + try + { + await speechHandle.WaitIfNotInterruptedAsync([round]).ConfigureAwait(false); + return !speechHandle.IsInterrupted && await round.ConfigureAwait(false); + } + finally + { + // The round reader stops before the scopes close, so it opens none behind this cleanup. + await TaskTeardown.CancelAndWaitAsync(roundCancellation, [round]).ConfigureAwait(false); + session.Scheduler.RemoveBackgroundSpeech(speechHandle); + closing.AddRange(scopes.Values.Select(scope => scope.DisposeAsync().AsTask())); + await Task.WhenAll(closing).ConfigureAwait(false); + } + } + + private void EndReply() + { + if (stream.Fault is { } fault) + { + // LiveKit's _on_llm_task_done: a genuine engine failure surfaces through the speech's error. + speechHandle.MarkDone(fault); + return; + } + + metrics.EndReply(); + + if (stream.Port.LastTurn?.Approvals is { Count: > 0 }) + { + _ = session.Say(PendingApprovalNotice); + } + } + + private async Task EndInterruptedAsync(CancellationToken cancellationToken) + { + bool started = Hearing.CutInterrupted(); + await Hearing.RecutToReportAsync(cancellationToken).ConfigureAwait(false); + + // A turn still waiting on the one before it takes the cut when it starts, and nothing here waits for it. + if (!started) + { + return; + } + + try + { + await stream.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The interruption backstop fired first; it marks the speech done itself. + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/ReplyEvent.cs b/src/AgentCore.AspNetCore/Voice/ReplyEvent.cs new file mode 100644 index 00000000..8bd12b57 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/ReplyEvent.cs @@ -0,0 +1,8 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// One event of an engine turn, in stream order. + /// What happened. + /// The text for , the call id for a tool event, else empty. + /// The tool a calls, else . + internal readonly record struct ReplyEvent(ReplyEventKind Kind, string Value, string? ToolName = null); +} diff --git a/src/AgentCore.AspNetCore/Voice/ReplyEventKind.cs b/src/AgentCore.AspNetCore/Voice/ReplyEventKind.cs new file mode 100644 index 00000000..c4ea93c4 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/ReplyEventKind.cs @@ -0,0 +1,18 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// What one read off the engine stream carries (plan 2.1). + internal enum ReplyEventKind + { + /// Non-empty text of the current step. + Text, + + /// A FunctionCallContent: the step's words are over and its tools start. + ToolCall, + + /// A FunctionResultContent. + ToolResult, + + /// The result that answers the last open call of the round: the next step starts. + RoundDone, + } +} diff --git a/src/AgentCore.AspNetCore/Voice/ReplyHearing.cs b/src/AgentCore.AspNetCore/Voice/ReplyHearing.cs new file mode 100644 index 00000000..d2295c0e --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/ReplyHearing.cs @@ -0,0 +1,214 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:3833-3863 +// (_pipeline_reply_task_impl, the interrupted reply's forwarded_text) and generation.py:737-768 (forward_generation, +// the interrupted branch's wait for playout), commit d8405f132e1bd960f298190c18daf81ffc1faf45. +// Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; the +// transport, not an audio sink, reports what played, and the reply's engine turn is cut rather than a chat +// context written. + +using AgentCore.Application.Runtime; + +namespace AgentCore.AspNetCore.Voice +{ + /// What the caller heard of one pipeline reply, and the cut its engine turn takes from it. + /// + /// With nothing forwarded the turn keeps the caller's message only; with text forwarded it keeps what the + /// transport reported the caller heard, or, with no report behind the interruption, the text forwarded, as + /// LiveKit keeps forwarded_text. A final prompt that cut a step mid-way may be followed by the report: + /// the reply waits for it, bounded, and recuts its turn to it. + /// + /// The engine turn the cut reaches. + /// The clock the wait for the report runs on. + /// How long a reply a final prompt cut mid-step waits for the transport's report. + /// Cancelled once, when the transport goes away and no report can come. + internal sealed class ReplyHearing( + EngineReplyStream stream, + TimeProvider time, + TimeSpan heardTextWait, + CancellationToken transportToken) + { + private static readonly TurnCut NothingHeard = new(string.Empty, Played: null); + + private readonly Lock _gate = new(); + + private readonly TaskCompletionSource _bargeRecorded = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private string _forwarded = string.Empty; + + private string _beforeLastSpokenStep = string.Empty; + + private (string HeardText, TimeSpan PlayedDuration)? _barge; + + private volatile bool _forwardedText; + + private bool _cutMidStep; + + private bool _expectsBarge; + + private bool _bargeWaitOver; + + private bool _cutSettled; + + /// Gets whether the reply has handed any text to the output yet. + public bool HasForwardedText => _forwardedText; + + /// Gets whether a final prompt interrupted the reply and it still takes the transport's report. + public bool AwaitsBarge + { + get + { + lock (_gate) + { + return _expectsBarge && !_bargeWaitOver; + } + } + } + + /// Marks the interruption about to land as a final prompt's, which the transport's report may follow. + public void ExpectBarge() + { + lock (_gate) + { + _expectsBarge = true; + } + } + + /// Keeps what the transport reported for the barge-in that interrupts, or interrupted, the reply. + /// The text the caller heard. + /// How much of the reply played. + /// + /// for the first report, which is the one kept; for a later one, + /// or for one that comes once the reply stopped waiting for it. + /// + public bool RecordBarge(string heardText, TimeSpan playedDuration) + { + lock (_gate) + { + if (_barge is not null || _bargeWaitOver) + { + return false; + } + + _barge = (heardText, playedDuration); + } + + _ = _bargeRecorded.TrySetResult(); + return true; + } + + /// Marks the first text of a step handed to the output. + public void MarkFirstText() + { + lock (_gate) + { + _beforeLastSpokenStep = _forwarded; + } + + _forwardedText = true; + } + + /// Keeps what one step forwarded, once its forwarding stopped. + /// What the step forwarded. + public void AddStep(TextForwardingResult forwarded) + { + lock (_gate) + { + _forwarded += forwarded.Text; + _cutMidStep = forwarded.Playback == TextPlayback.Partial; + } + } + + /// Cuts the reply's engine turn to what the caller heard, without interrupting its speech. + /// The text the caller heard, as the transport reported it. + /// How much of the reply played, as the transport reported it. + public void CutHeard(string heardText, TimeSpan playedDuration) + { + _ = stream.Cut(Heard(heardText, playedDuration)); + } + + /// Cuts the reply's engine turn to what reached the caller, as its interruption does. + /// when the turn has started, so its end is worth waiting for. + public bool CutInterrupted() + { + TurnCut cut; + lock (_gate) + { + bool forwardedText = _forwardedText; + _cutSettled = !forwardedText || _barge is not null; + cut = !forwardedText + ? NothingHeard + : _barge is { } heard ? Heard(heard.HeardText, heard.PlayedDuration) : new TurnCut(_forwarded, Played: null); + } + + return stream.Cut(cut); + } + + /// + /// After a final prompt cut a step mid-way, waits for the transport's report of what was heard, as LiveKit + /// waits for playout before it keeps the synchronized transcript, and recuts the turn to it. + /// + /// The speech's own token, which the interruption backstop cancels. + public async Task RecutToReportAsync(CancellationToken cancellationToken) + { + await WaitForBargeAsync(cancellationToken).ConfigureAwait(false); + + TurnCut cut; + lock (_gate) + { + if (_cutSettled || _barge is not { } heard) + { + return; + } + + _cutSettled = true; + cut = Heard(heard.HeardText, heard.PlayedDuration); + } + + stream.Recut(cut); + } + + /// Builds the cut for a transport report, which covers only the step whose text played last. + private TurnCut Heard(string heardText, TimeSpan playedDuration) + { + lock (_gate) + { + return new TurnCut(_beforeLastSpokenStep + heardText, playedDuration); + } + } + + private async Task WaitForBargeAsync(CancellationToken cancellationToken) + { + bool wait; + lock (_gate) + { + wait = _expectsBarge && _cutMidStep && _barge is null; + _bargeWaitOver = !wait; + } + + if (!wait) + { + return; + } + + using CancellationTokenSource gone = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, transportToken); + try + { + await _bargeRecorded.Task.WaitAsync(heardTextWait, time, gone.Token).ConfigureAwait(false); + } + catch (TimeoutException) + { + // No report: the transport had nothing more to say about this step. + } + catch (OperationCanceledException) when (gone.IsCancellationRequested) + { + // The transport went away or the backstop fired: no report can come now. + } + finally + { + lock (_gate) + { + _bargeWaitOver = true; + } + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SayReply.cs b/src/AgentCore.AspNetCore/Voice/SayReply.cs new file mode 100644 index 00000000..2c19ac62 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SayReply.cs @@ -0,0 +1,54 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:3100-3356 +// (_tts_task, _tts_task_impl), text-only lines 3144-3158, 3160-3173, 3245-3261, 3274-3278, 3346-3352, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; no TTS, no audio +// forwarding, no chat-context write (G4), no metrics (2.5: say/filler report none). + +namespace AgentCore.AspNetCore.Voice +{ + /// Speaks fixed text: a say, a filler, or a notice — never a model reply. + internal static class SayReply + { + /// Runs one say from authorization to its end. + /// The speech this text belongs to. + /// Where the agent state this speech drives is read and set. + /// Where the text goes. + /// The text, one fragment at a time. + /// The speech's own task token. + public static async Task RunAsync( + SpeechHandle speechHandle, + VoiceSession session, + IConversationOutputPort output, + IAsyncEnumerable text, + CancellationToken cancellationToken) + { + List authorizationTasks = [speechHandle.WaitForAuthorizationAsync(cancellationToken)]; + if (speechHandle.AllowInterruptions) + { + authorizationTasks.Add(session.WaitForUserSilenceAsync(cancellationToken)); + } + + await speechHandle.WaitIfNotInterruptedAsync(authorizationTasks).ConfigureAwait(false); + speechHandle.ClearAuthorization(); + + if (speechHandle.IsInterrupted) + { + return; + } + + _ = await TextForwarding.ForwardAsync( + speechHandle, + output, + text, + onFirstText: () => session.SetAgentState(AgentState.Speaking), + cancellationToken).ConfigureAwait(false); + + // Only when this speech is still the one that put the session into "speaking": an + // interruption that landed before the first fragment never made that change. + if (session.AgentState == AgentState.Speaking) + { + session.SetAgentState(session.HasBackgroundSpeeches ? AgentState.Thinking : AgentState.Listening); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SpeechDoneCallbacks.cs b/src/AgentCore.AspNetCore/Voice/SpeechDoneCallbacks.cs new file mode 100644 index 00000000..0925a9ea --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SpeechDoneCallbacks.cs @@ -0,0 +1,97 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/speech_handle.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// The callbacks waiting for one to be done. + /// + /// Every callback runs on the thread pool, never on the thread that marked the speech done, as asyncio's + /// call_soon runs it on a later loop turn. That thread may hold the session lock. + /// + /// The speech every callback is handed. + /// Where a throwing callback is reported. + internal sealed class SpeechDoneCallbacks(SpeechHandle owner, ILogger logger) + { + private readonly Lock _gate = new(); + + private readonly List> _callbacks = []; + + private bool _fired; + + /// Registers a callback, or queues it at once if the speech is already done. + /// The callback. Registering it twice keeps one. + public void Add(Action callback) + { + lock (_gate) + { + if (!_fired) + { + if (!_callbacks.Contains(callback)) + { + _callbacks.Add(callback); + } + + return; + } + } + + _ = ThreadPool.UnsafeQueueUserWorkItem( + static state => state.Self.Invoke(state.Callback), (Self: this, Callback: callback), preferLocal: false); + } + + /// Removes a registered callback, if it has not run yet. + /// The callback. + public void Remove(Action callback) + { + lock (_gate) + { + _ = _callbacks.Remove(callback); + } + } + + /// Queues every registered callback, once. Later calls do nothing. + public void Fire() + { + lock (_gate) + { + if (_fired) + { + return; + } + + _fired = true; + } + + _ = ThreadPool.UnsafeQueueUserWorkItem(static self => self.RunAll(), this, preferLocal: false); + } + + private void RunAll() + { + Action[] callbacks; + lock (_gate) + { + callbacks = [.. _callbacks]; + } + + foreach (Action callback in callbacks) + { + Invoke(callback); + } + } + + private void Invoke(Action callback) + { + try + { + callback(owner); + } + catch (Exception ex) + { + VoiceConversationLog.SpeechDoneCallbackFaulted(logger, owner.Id, ex); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SpeechHandle.cs b/src/AgentCore.AspNetCore/Voice/SpeechHandle.cs new file mode 100644 index 00000000..14ca4aa9 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SpeechHandle.cs @@ -0,0 +1,357 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/speech_handle.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; audio paths removed. + +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// One speech the agent means to say, a reply or a say: whether it may be interrupted, whether it + /// was, and when it is done. + /// + internal sealed class SpeechHandle + { + /// How long an interrupted speech may take to finish before it is cancelled and marked done. + public static readonly TimeSpan InterruptionTimeout = TimeSpan.FromSeconds(5); + + private readonly Lock _gate = new(); + + private readonly TimeProvider _time; + + private readonly ILogger _logger; + + private readonly TaskCompletionSource _interrupted = NewSource(); + + private readonly TaskCompletionSource _done = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _scheduled = NewSource(); + + private readonly AsyncEvent _authorize = new(); + + private readonly List _generations = []; + + private readonly SpeechTasks _tasks = new(); + + private readonly SpeechDoneCallbacks _doneCallbacks; + + private bool _allowInterruptions; + + private int _numSteps = 1; + + private InterruptionSource? _interruptSource; + + private Exception? _error; + + private SpeechHandle(string id, bool allowInterruptions, TimeProvider time, ILogger logger) + { + Id = id; + _allowInterruptions = allowInterruptions; + _time = time; + _logger = logger; + _doneCallbacks = new SpeechDoneCallbacks(this, logger); + } + + /// Gets the id of this speech, speech_ and 12 hex digits. + public string Id { get; } + + /// Gets how many model steps this speech has taken, starting at 1. + public int NumSteps => Volatile.Read(ref _numSteps); + + /// Gets whether the scheduler has queued this speech. + public bool IsScheduled => _scheduled.Task.IsCompleted; + + /// Gets whether this speech was interrupted. + public bool IsInterrupted => _interrupted.Task.IsCompleted; + + /// Gets whether this speech is done, interrupted or not. + public bool IsDone => _done.Task.IsCompleted; + + /// Gets the cause of the first interruption, or when there was none. + public InterruptionSource? InterruptSource + { + get + { + lock (_gate) + { + return _interruptSource; + } + } + } + + /// Gets or sets whether an interruption without force may cut this speech. + /// Set to on an interrupted speech. + public bool AllowInterruptions + { + get + { + lock (_gate) + { + return _allowInterruptions; + } + } + + set + { + lock (_gate) + { + if (IsInterrupted && !value) + { + throw new InvalidOperationException( + "Cannot set allow_interruptions to False, the SpeechHandle is already interrupted"); + } + + _allowInterruptions = value; + } + } + } + + /// Gets the error the speech failed with, or . Awaiting the handle never raises it. + /// The speech is not done yet. + public Exception? Error + { + get + { + lock (_gate) + { + return IsDone ? _error : throw new InvalidOperationException("SpeechHandle is not done yet"); + } + } + } + + /// Gets the token the speech's own tasks honour. Only the interruption backstop cancels it. + public CancellationToken TaskCancellationToken => _tasks.Token; + + /// Creates a speech with a fresh id. + /// The clock the interruption backstop and the queue wait run on. + /// Where the backstop and a throwing done callback are reported. + /// Whether an interruption without force may cut it. + public static SpeechHandle Create(TimeProvider time, ILogger logger, bool allowInterruptions = true) + { + return new SpeechHandle("speech_" + Guid.NewGuid().ToString("N")[..12], allowInterruptions, time, logger); + } + + /// Lets an awaiter wait for the speech to be done. It never raises . + public System.Runtime.CompilerServices.TaskAwaiter GetAwaiter() + { + return _done.Task.GetAwaiter(); + } + + /// Interrupts the speech. + /// Interrupt even when the speech disallows interruptions. + /// Why. Only the first interruption's source is kept. + /// This handle. + /// The speech is running and disallows interruptions. + public SpeechHandle Interrupt(bool force = false, InterruptionSource source = InterruptionSource.Programmatic) + { + lock (_gate) + { + if (IsInterrupted || IsDone) + { + return this; + } + + if (!force && !_allowInterruptions) + { + throw new InvalidOperationException("This generation handle does not allow interruptions"); + } + + _interruptSource = source; + CancelLocked(); + return this; + } + } + + /// Waits until the speech is done. It never raises . + /// Cancels this wait only, never the speech. + public Task WaitForPlayoutAsync(CancellationToken cancellationToken = default) + { + return _done.Task.WaitAsync(cancellationToken); + } + + /// Calls on the thread pool once the speech is done, or soon if it is already. + /// Runs outside every lock. A throw is logged, not raised. + public void AddDoneCallback(Action callback) + { + _doneCallbacks.Add(callback); + } + + /// Removes a callback registered, if it has not run yet. + /// The callback to remove. + public void RemoveDoneCallback(Action callback) + { + _doneCallbacks.Remove(callback); + } + + /// Waits for every task in , or for an interruption, whichever comes first. + /// Waited on, never cancelled. A fault in one is not raised here. + public async Task WaitIfNotInterruptedAsync(IEnumerable tasks) + { + Task all = Task.WhenAll(tasks).ContinueWith( + static finished => _ = finished.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + _ = await Task.WhenAny(all, _interrupted.Task).ConfigureAwait(false); + } + + /// Adds one to , when a tool round schedules the next model step. + internal void IncrementNumSteps() + { + _ = Interlocked.Increment(ref _numSteps); + } + + /// Links a task to this speech. The backstop cancels it; its owner marks the speech done once all are. + /// A task started for this speech. + internal void AddTask(Task task) + { + _tasks.Add(task); + } + + /// Marks the speech done if every task linked to it has finished. + internal void MarkDoneIfTasksDone() + { + if (_tasks.AllDone) + { + MarkDone(); + } + } + + /// + /// Opens a new generation and the authorization gate, unless the speech is already done. + /// + /// when the speech is done, so no generation was opened. + internal bool TryAuthorizeGeneration() + { + lock (_gate) + { + if (IsDone) + { + return false; + } + + _generations.Add(NewSource()); + _authorize.Set(); + return true; + } + } + + /// Closes the authorization gate once the speech's task has passed it. + internal void ClearAuthorization() + { + _authorize.Clear(); + } + + /// Waits until the scheduler authorizes a generation. + /// Cancels this wait only. + internal Task WaitForAuthorizationAsync(CancellationToken cancellationToken = default) + { + return _authorize.WaitAsync(cancellationToken); + } + + /// Gets whether any generation was ever authorized. + internal bool HasGenerations + { + get + { + lock (_gate) + { + return _generations.Count > 0; + } + } + } + + /// Waits until a generation is marked done. + /// Which generation, counted from the end when negative, as a Python index. + /// No generation was authorized. + internal Task WaitForGenerationAsync(int stepIndex = -1) + { + lock (_gate) + { + if (_generations.Count == 0) + { + throw new InvalidOperationException("cannot use wait_for_generation: no active generation is running."); + } + + return _generations[stepIndex < 0 ? _generations.Count + stepIndex : stepIndex].Task; + } + } + + /// Waits until the scheduler has queued this speech. + internal Task WaitForScheduledAsync() + { + return _scheduled.Task; + } + + /// Marks the latest generation done, which lets the scheduler move to the next speech. + /// No generation was authorized. + internal void MarkGenerationDone() + { + lock (_gate) + { + if (_generations.Count == 0) + { + throw new InvalidOperationException("cannot use mark_generation_done: no active generation is running."); + } + + _ = _generations[^1].TrySetResult(); + } + } + + /// Marks the speech done. The first call wins; later calls still close the generation and the backstop. + /// Why the speech failed, kept for and never raised to an awaiter. + internal void MarkDone(Exception? error = null) + { + bool firstDone = false; + lock (_gate) + { + if (!IsDone) + { + _error = error ?? _error; + _ = _done.TrySetResult(this); + firstDone = true; + } + + if (_generations.Count > 0) + { + _ = _generations[^1].TrySetResult(); + } + } + + _tasks.DisarmBackstop(); + if (firstDone) + { + _doneCallbacks.Fire(); + } + } + + /// Releases once the scheduler has queued this speech. + internal void MarkScheduled() + { + _ = _scheduled.TrySetResult(); + } + + private static TaskCompletionSource NewSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private void CancelLocked() + { + if (IsDone || !_interrupted.TrySetResult()) + { + return; + } + + _tasks.ArmBackstop(_time, InterruptionTimeout, OnInterruptionTimeout); + } + + private void OnInterruptionTimeout() + { + VoiceConversationLog.SpeechNotDoneAfterInterruption(_logger, Id, InterruptionTimeout.TotalSeconds); + _tasks.Cancel(); + MarkDone(); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SpeechPriority.cs b/src/AgentCore.AspNetCore/Voice/SpeechPriority.cs new file mode 100644 index 00000000..38ebf346 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SpeechPriority.cs @@ -0,0 +1,19 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/speech_handle.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// How a speech is ordered against others waiting in the speech queue. + internal enum SpeechPriority + { + /// Played after every other message already in the queue. + Low = 0, + + /// The default priority for every speech the agent generates. + Normal = 5, + + /// An important message, played before others. + High = 10, + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SpeechQueue.cs b/src/AgentCore.AspNetCore/Voice/SpeechQueue.cs new file mode 100644 index 00000000..e0392392 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SpeechQueue.cs @@ -0,0 +1,43 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +using System.Diagnostics.CodeAnalysis; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// The speeches waiting to play: highest priority first, first queued first within a priority. + /// + internal sealed class SpeechQueue + { + private readonly PriorityQueue _heap = new(); + + private long _seq; + + /// Gets how many speeches are waiting. + public int Count => _heap.Count; + + /// Queues a speech behind every one of the same or higher priority. + /// The speech to queue. + /// Its priority. + public void Enqueue(SpeechHandle speech, SpeechPriority priority) + { + _heap.Enqueue(speech, (-(int)priority, _seq++)); + } + + /// Takes the speech that plays next. + /// The speech, when there was one. + /// when the queue is empty. + public bool TryDequeue([MaybeNullWhen(false)] out SpeechHandle speech) + { + return _heap.TryDequeue(out speech, out _); + } + + /// Lists the waiting speeches in the order they would play. The heap's own order is not that. + public IReadOnlyList SortedSnapshot() + { + return [.. _heap.UnorderedItems.OrderBy(static item => item.Priority).Select(static item => item.Element)]; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SpeechScheduler.cs b/src/AgentCore.AspNetCore/Voice/SpeechScheduler.cs new file mode 100644 index 00000000..be819c53 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SpeechScheduler.cs @@ -0,0 +1,388 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; audio paths removed. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Plays one speech at a time from the speech queue, and interrupts the current, queued and background + /// speeches on request. + /// + /// The one lock of the voice session. Never held across an await. + /// Where scheduling warnings and speech task faults are reported. + internal sealed class SpeechScheduler(Lock sessionLock, ILogger logger) + { + private readonly Lock _lock = sessionLock; + + private readonly SpeechQueue _speechQueue = new(); + + private readonly BackgroundSpeeches _backgroundSpeeches = new(); + + private readonly List _speechTasks = []; + + private readonly AsyncEvent _queueUpdated = new(); + + private SpeechHandle? _currentSpeech; + + private bool _schedulingPaused = true; + + private Task? _schedulingTask; + + private Task? _userTurnTask; + + /// Gets the speech playing now, or . + public SpeechHandle? CurrentSpeech + { + get + { + lock (_lock) + { + return _currentSpeech; + } + } + } + + /// Gets whether no speech is queued and the current one, if any, is done. + public bool NoPendingSpeech + { + get + { + lock (_lock) + { + return _speechQueue.Count == 0 && (_currentSpeech is null || _currentSpeech.IsDone); + } + } + } + + /// Gets whether a speech finished speaking but still waits for its tools. + public bool HasBackgroundSpeeches + { + get + { + lock (_lock) + { + return _backgroundSpeeches.Any; + } + } + } + + /// Gets whether the scheduler is refusing new speech (a forced continuation still gets through). + public bool SchedulingPaused + { + get + { + lock (_lock) + { + return _schedulingPaused; + } + } + } + + /// Gets the latest user-turn task recorded, or . + public Task? UserTurnTask + { + get + { + lock (_lock) + { + return _userTurnTask; + } + } + } + + /// Records the latest user-turn task, so can wait for it (LiveKit + /// agent_activity.py:2092-2094) without ever cancelling it. + /// The task, or to stop tracking one. + public void SetUserTurnTask(Task? task) + { + lock (_lock) + { + _userTurnTask = task; + } + } + + /// Starts a task that may create or play a speech, and links it to . + /// The task body. A fault is logged, then left on the returned task. + /// The speech the task works for. It is marked done once all its tasks are. + /// The running task. + public Task CreateSpeechTask(Func body, SpeechHandle? speechHandle = null) + { + Task task = Task.Run(TaskTeardown.LogExceptions(body, ex => VoiceConversationLog.SpeechTaskFaulted(logger, ex))); + lock (_lock) + { + _speechTasks.Add(task); + } + + speechHandle?.AddTask(task); + + // Attached after both lists hold the task, so the removal can never run before the add. + _ = task.ContinueWith( + (finished, state) => OnSpeechTaskDone(finished, (SpeechHandle?)state), + speechHandle, + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + return task; + } + + /// Queues a speech to play. + /// The speech. + /// Where it goes in the queue. + /// Queue it even while scheduling is pausing, as the next step of a tool reply must. + /// Scheduling is paused and is not set. + public void ScheduleSpeech(SpeechHandle speech, SpeechPriority priority, bool force = false) + { + lock (_lock) + { + if (_schedulingPaused && !force) + { + _ = speech.Interrupt(force: true); + throw new InvalidOperationException( + "cannot schedule new speech, the speech scheduling is draining/pausing, the speech will be cancelled"); + } + + if (_schedulingTask is { IsCompleted: true }) + { + VoiceConversationLog.SchedulingTaskNotRunning(logger, speech.Id); + _ = speech.Interrupt(force: true); + return; + } + + _speechQueue.Enqueue(speech, priority); + speech.MarkScheduled(); + _queueUpdated.Set(); + } + } + + /// Interrupts the background speeches, the current speech, and the queue up to the first protected speech. + /// Interrupt even speeches that disallow interruptions. + /// Why, recorded on each speech that had no interruption yet. + /// A task that completes when every speech this interrupted is done. + /// + /// The current speech disallows interruptions and is not set. The background + /// speeches are already interrupted by then; the queue is not. + /// + public Task Interrupt(bool force = false, InterruptionSource source = InterruptionSource.Programmatic) + { + List interrupted; + lock (_lock) + { + interrupted = _backgroundSpeeches.InterruptAll(force, source); + + if (_currentSpeech is not null) + { + _ = _currentSpeech.Interrupt(force, source); + interrupted.Add(_currentSpeech); + } + + foreach (SpeechHandle speech in _speechQueue.SortedSnapshot()) + { + try + { + _ = speech.Interrupt(force, source); + } + catch (InvalidOperationException) + { + // The speeches behind it will play, so stopping here keeps the conversation contiguous. + VoiceConversationLog.QueuedSpeechNotInterruptible(logger, speech.Id); + break; + } + + interrupted.Add(speech); + } + } + + if (interrupted.Count == 0) + { + return Task.CompletedTask; + } + + TaskCompletionSource playoutDone = new(TaskCreationOptions.RunContinuationsAsynchronously); + foreach (SpeechHandle speech in interrupted) + { + speech.AddDoneCallback(finished => + { + if (interrupted.TrueForAll(static handle => handle.IsDone)) + { + _ = playoutDone.TrySetResult(); + } + }); + } + + return playoutDone.Task; + } + + /// Interrupts every background speech that allows it, or all of them with . + /// Interrupt even speeches that disallow interruptions. + /// Why. + /// The speeches interrupted. + public IReadOnlyList InterruptBackgroundSpeeches(bool force = false, InterruptionSource source = InterruptionSource.Programmatic) + { + lock (_lock) + { + return _backgroundSpeeches.InterruptAll(force, source); + } + } + + /// Marks a speech as waiting for its tools after its words were spoken. + /// The speech. + public void AddBackgroundSpeech(SpeechHandle speech) + { + lock (_lock) + { + _backgroundSpeeches.Add(speech); + } + } + + /// Removes a speech added, once its tools finished. + /// The speech. + public void RemoveBackgroundSpeech(SpeechHandle speech) + { + lock (_lock) + { + _backgroundSpeeches.Remove(speech); + } + } + + /// Starts the scheduling loop, if scheduling is paused. A new scheduler starts paused. + public void ResumeScheduling() + { + lock (_lock) + { + if (!_schedulingPaused) + { + return; + } + + _schedulingPaused = false; + _schedulingTask = Task.Run(TaskTeardown.LogExceptions( + SchedulingLoopAsync, ex => VoiceConversationLog.SchedulingLoopFaulted(logger, ex))); + } + } + + /// Stops taking new speeches, then waits until the loop has played the queue and every speech task ended. + /// A task that completes when the scheduling loop has exited. + public async Task PauseSchedulingAsync() + { + Task? schedulingTask; + lock (_lock) + { + if (_schedulingPaused) + { + return; + } + + _schedulingPaused = true; + _queueUpdated.Set(); + schedulingTask = _schedulingTask; + } + + if (schedulingTask is not null) + { + await schedulingTask.ConfigureAwait(false); + } + } + + /// Waits until no speech is current or queued, and no user turn is still being completed. + /// Cancels this wait only, never a speech or the user turn. + /// A task that completes once the agent and the user side are both idle. + public async Task WaitForIdleAsync(CancellationToken cancellationToken = default) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + SpeechHandle? speech; + Task? userTurnTask; + lock (_lock) + { + userTurnTask = _userTurnTask is { IsCompleted: false } pending ? pending : null; + if (_currentSpeech is null && _speechQueue.Count == 0 && userTurnTask is null) + { + return; + } + + speech = _currentSpeech; + } + + // Shielded, as LiveKit's wait_for_idle shields the user-turn task: this wait may be + // cancelled without cancelling the turn itself. + if (userTurnTask is not null) + { + await userTurnTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + if (speech is { HasGenerations: true }) + { + await speech.WaitForGenerationAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + } + + await Task.Yield(); + } + } + + private void OnSpeechTaskDone(Task task, SpeechHandle? speechHandle) + { + // Observed here: LogExceptions already logged the fault, and a user-turn task has nobody else to await it. + _ = task.Exception; + lock (_lock) + { + _ = _speechTasks.Remove(task); + } + + speechHandle?.MarkDoneIfTasksDone(); + _queueUpdated.Set(); + } + + private async Task SchedulingLoopAsync() + { + while (true) + { + await _queueUpdated.WaitAsync().ConfigureAwait(false); + + // Cleared before the queue is read: a speech queued after this point sets the event again. + _queueUpdated.Clear(); + + while (TryTakeNextSpeech(out SpeechHandle? speech)) + { + await speech.WaitForGenerationAsync().ConfigureAwait(false); + lock (_lock) + { + _currentSpeech = null; + } + } + + lock (_lock) + { + if (_schedulingPaused && _speechTasks.Count == 0) + { + return; + } + } + } + } + + private bool TryTakeNextSpeech([NotNullWhen(true)] out SpeechHandle? speech) + { + lock (_lock) + { + while (_speechQueue.TryDequeue(out speech)) + { + _currentSpeech = speech; + if (speech.TryAuthorizeGeneration()) + { + return true; + } + + // Done while queued: interrupted there, or cut by its backstop. + _currentSpeech = null; + } + + return false; + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/SpeechTasks.cs b/src/AgentCore.AspNetCore/Voice/SpeechTasks.cs new file mode 100644 index 00000000..af2e0a36 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/SpeechTasks.cs @@ -0,0 +1,103 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/speech_handle.py and +// livekit-agents/livekit/agents/voice/agent_activity.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +using System.Diagnostics.CodeAnalysis; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// The tasks one runs, the token they honour, and the interruption backstop + /// that cancels them. + /// + [SuppressMessage( + "Design", + "CA1001:Types that own disposable fields should be disposable", + Justification = "Tasks keep the token past the speech's end, and a source with no timer and no wait handle holds nothing to release.")] + internal sealed class SpeechTasks + { + private readonly Lock _gate = new(); + + private readonly List _tasks = []; + + private readonly CancellationTokenSource _cancellation = new(); + + private ITimer? _backstop; + + private Action? _onBackstop; + + /// Gets the token every task of the speech honours. + public CancellationToken Token => _cancellation.Token; + + /// Gets whether every task added so far has finished. + public bool AllDone + { + get + { + lock (_gate) + { + return _tasks.TrueForAll(static task => task.IsCompleted); + } + } + } + + /// Links a task to the speech. + /// A task started for the speech. + public void Add(Task task) + { + lock (_gate) + { + _tasks.Add(task); + } + } + + /// Cancels . Registered callbacks run on the caller's thread, so no lock may be held. + public void Cancel() + { + _cancellation.Cancel(); + } + + /// Arms the backstop, once. + /// The clock the timer runs on. + /// How long until it fires. + /// Runs on the timer thread, outside this lock, unless ran first. + public void ArmBackstop(TimeProvider time, TimeSpan dueTime, Action onBackstop) + { + lock (_gate) + { + _onBackstop = onBackstop; + _backstop = time.CreateTimer( + static state => ((SpeechTasks)state!).OnBackstop(), this, dueTime, Timeout.InfiniteTimeSpan); + } + } + + /// Disarms the backstop, if armed. A callback already dispatched then does nothing. + public void DisarmBackstop() + { + _ = TakeBackstop(); + } + + private void OnBackstop() + { + // A timer callback already dispatched can still run after DisarmBackstop disposed the timer. + TakeBackstop()?.Invoke(); + } + + private Action? TakeBackstop() + { + ITimer? backstop; + Action? onBackstop; + lock (_gate) + { + backstop = _backstop; + onBackstop = _onBackstop; + _backstop = null; + _onBackstop = null; + } + + backstop?.Dispose(); + return backstop is null ? null : onBackstop; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/TaskTeardown.cs b/src/AgentCore.AspNetCore/Voice/TaskTeardown.cs new file mode 100644 index 00000000..6deaab14 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/TaskTeardown.cs @@ -0,0 +1,55 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/utils/aio/utils.py +// (cancel_and_wait) and livekit-agents/livekit/agents/utils/log.py (log_exceptions), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// Cancellation and fault-logging helpers a speech task's owner tears it down with. + internal static class TaskTeardown + { + /// Cancels , then waits for every task to finish. + /// Cancelled once, before any task is awaited. + /// The tasks racing that cancellation. + /// + /// A task that completes once every task in has finished, however it + /// finished. A task's own fault is not raised here: its wrapper + /// already reported it. + /// + public static async Task CancelAndWaitAsync(CancellationTokenSource cancellation, IEnumerable tasks) + { + cancellation.Cancel(); + + foreach (Task task in tasks) + { + try + { + await task.ConfigureAwait(false); + } + catch (Exception) + { + // Waited on, never raised: LiveKit's cancel_and_wait awaits a done-callback, not the task. + } + } + } + + /// Wraps a speech task body so a fault is logged once, then rethrown to its owner. + /// The task body to run. + /// Logs a fault raised. Must not throw. + public static Func LogExceptions(Func action, Action logFault) + { + return async () => + { + try + { + await action().ConfigureAwait(false); + } + catch (Exception ex) + { + logFault(ex); + throw; + } + }; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/TextForwarding.cs b/src/AgentCore.AspNetCore/Voice/TextForwarding.cs new file mode 100644 index 00000000..c3961f4c --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/TextForwarding.cs @@ -0,0 +1,108 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py:531-569 +// (_TextOutput, perform_text_forwarding, _text_forwarding_task), 679-784 (_ForwardOutput, +// forward_generation, text-only branches 729-738, 770-771, 780-781), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; every audio branch removed; +// each forwarding begins its own reply on the output. + +using System.Text; + +namespace AgentCore.AspNetCore.Voice +{ + /// Forwards one generation's text to , honouring interruption. + internal static class TextForwarding + { + /// + /// Streams to until it ends or + /// is interrupted, then closes the reply. + /// + /// The speech this forwarding belongs to. + /// Where every fragment goes. + /// + /// The text, one fragment at a time. May yield an empty fragment; that fragment still counts + /// toward but is never handed to , + /// matching 's "never empty" contract. + /// + /// + /// Runs once, when the first fragment (even an empty one) arrives, on the forwarding task itself, so + /// it has finished before this method returns. + /// + /// + /// The speech's own task token. Interruption cancels forwarding at once rather than waiting for + /// this token's own deadline (LiveKit's cancel_and_wait). + /// + /// What was forwarded, once forwarding has stopped for any reason. + public static async Task ForwardAsync( + SpeechHandle speechHandle, + IConversationOutputPort output, + IAsyncEnumerable source, + Action? onFirstText, + CancellationToken cancellationToken) + { + using CancellationTokenSource forwardCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + StringBuilder text = new(); + + // Before the task starts, so a barge-in's StopAsync also cuts off a fragment that already passed the + // IsInterrupted check below and is still on its way to the output. + output.BeginReply(); + Task forwardTask = ForwardTextAsync(speechHandle, output, source, text, onFirstText, forwardCancellation.Token, cancellationToken); + await speechHandle.WaitIfNotInterruptedAsync([forwardTask]).ConfigureAwait(false); + + if (speechHandle.IsInterrupted) + { + await TaskTeardown.CancelAndWaitAsync(forwardCancellation, [forwardTask]).ConfigureAwait(false); + } + + string forwarded = text.ToString(); + TextPlayback playback = forwarded.Length == 0 + ? TextPlayback.Skipped + : speechHandle.IsInterrupted ? TextPlayback.Partial : TextPlayback.Full; + return new TextForwardingResult(forwarded, playback); + } + + private static async Task ForwardTextAsync( + SpeechHandle speechHandle, + IConversationOutputPort output, + IAsyncEnumerable source, + StringBuilder text, + Action? onFirstText, + CancellationToken forwardCancellationToken, + CancellationToken completeCancellationToken) + { + bool firstTextSeen = false; + try + { + await foreach (string delta in source.WithCancellation(forwardCancellationToken).ConfigureAwait(false)) + { + // asyncio cancels this task on the loop that ran interrupt(); here the interrupt lands on + // another thread, and a delta read before the cancellation arrives must not be spoken. + if (speechHandle.IsInterrupted) + { + break; + } + + _ = text.Append(delta); + if (!firstTextSeen) + { + firstTextSeen = true; + + // Inline, not queued: the caller's "is the agent still speaking?" check after + // forwarding must see this, as asyncio's first_text_fut callback guarantees. + onFirstText?.Invoke(); + } + + if (delta.Length > 0) + { + await output.SpeakAsync(delta, forwardCancellationToken).ConfigureAwait(false); + } + } + } + finally + { + // Flushes even when cancelled above: LiveKit's flush() runs in the forwarding task's own + // finally block, not in forward_generation's interruption branch. + await output.CompleteAsync(completeCancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/TextForwardingResult.cs b/src/AgentCore.AspNetCore/Voice/TextForwardingResult.cs new file mode 100644 index 00000000..948adadb --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/TextForwardingResult.cs @@ -0,0 +1,12 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py:678-695 +// (_ForwardOutput, text-only fields), commit d8405f132e1bd960f298190c18daf81ffc1faf45. +// Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. +// Modified: translated to C#; every audio field dropped. + +namespace AgentCore.AspNetCore.Voice +{ + /// What forwarded. + /// Every fragment the source yielded, concatenated in order. + /// Whether it reached the caller in full, in part, or not at all. + internal readonly record struct TextForwardingResult(string Text, TextPlayback Playback); +} diff --git a/src/AgentCore.AspNetCore/Voice/TextPlayback.cs b/src/AgentCore.AspNetCore/Voice/TextPlayback.cs new file mode 100644 index 00000000..01b81aa5 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/TextPlayback.cs @@ -0,0 +1,21 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py:679-693 +// (_ForwardOutput.played, text-only branches), commit d8405f132e1bd960f298190c18daf81ffc1faf45. +// Copyright 2023 LiveKit, Inc. Licensed under the Apache License, Version 2.0. +// Modified: translated to C#; the "full"/"partial"/"skipped" values LiveKit's audio playback can also +// reach are dropped, since nothing here plays audio. + +namespace AgentCore.AspNetCore.Voice +{ + /// Whether a forwarded piece of text reached the caller, in full, in part, or not at all. + internal enum TextPlayback + { + /// Nothing was ever forwarded. + Skipped, + + /// Some text reached the output before the speech was interrupted. + Partial, + + /// Every fragment reached the output and the speech was not interrupted. + Full, + } +} diff --git a/src/AgentCore.AspNetCore/Voice/ToolFillerScope.cs b/src/AgentCore.AspNetCore/Voice/ToolFillerScope.cs new file mode 100644 index 00000000..cef97a91 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/ToolFillerScope.cs @@ -0,0 +1,31 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:112-153 +// (RunContext.with_filler), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, +// Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; a tool cannot open +// this itself (G5), so it is a scope PipelineReply opens from voice options on a FunctionCallContent and +// closes on the matching FunctionResultContent or the speech's interruption (plan 2.4), rather than an +// async context manager a tool's own code enters. + +namespace AgentCore.AspNetCore.Voice +{ + /// The filler scheduled for one tool call, open for as long as its step is still running. + internal sealed class ToolFillerScope : IAsyncDisposable + { + private readonly FillerScheduler _scheduler; + + /// Where idleness, state changes, and say come from. + /// The speech this tool call's step belongs to. + /// This tool id's filler, from voice options (plan Q1/owner ruling). + public ToolFillerScope(VoiceSession session, SpeechHandle speechHandle, FillerOptions options) + { + _scheduler = new FillerScheduler(session, speechHandle, options.Source, options.Delay, options.Interval, options.MaxSteps); + } + + /// Gets every speech this scope's filler created, in fire order. + public IReadOnlyList CreatedSpeeches => _scheduler.CreatedSpeeches; + + public ValueTask DisposeAsync() + { + return new ValueTask(_scheduler.CloseAsync()); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/TurnMetrics.cs b/src/AgentCore.AspNetCore/Voice/TurnMetrics.cs new file mode 100644 index 00000000..441bbbc1 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/TurnMetrics.cs @@ -0,0 +1,87 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/generation.py (perform_llm_inference, +// ttft) and livekit-agents/livekit/agents/voice/agent_activity.py (_pipeline_reply_task_impl metrics, interrupt), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; the four readings go to +// AgentCoreTelemetry under AgentCore's own metric names, and every audio-only reading is dropped. + +using AgentCore.Application.Diagnostics; + +namespace AgentCore.AspNetCore.Voice +{ + /// Times one pipeline reply at the points LiveKit measures it (plan 2.5). + /// The clock every reading is taken on. + /// + /// The at which the caller's final words arrived, or + /// for a reply that answers no caller turn, which then reports no first-speech + /// and no reply-end reading. + /// + internal sealed class TurnMetrics(TimeProvider time, long? userTurnEndedAt) + { + private long _stepStartedAt; + + private bool _stepContentSeen = true; + + private int _firstSpeechMarked; + + private int _replyEndMarked; + + private long? _speechEndedAt; + + /// Records how long an audio-activity interruption took to take effect. + /// The clock was read on. + /// When the barge-in arrived. + /// Called once every speech it interrupted is done, as LiveKit's interrupt() future resolves. + public static void RecordBargeIn(TimeProvider time, long detectedAt) + { + AgentCoreTelemetry.RecordBargeInLatency(time.GetElapsedTime(detectedAt)); + } + + /// Marks the start of one model step: the engine call for step 1, the end of the tool round before for any later step. + public void StartStep() + { + _stepStartedAt = time.GetTimestamp(); + _stepContentSeen = false; + } + + /// Marks the first text or tool call of the current step. Only the first per step records a reading. + public void MarkFirstContent() + { + if (_stepContentSeen) + { + return; + } + + _stepContentSeen = true; + AgentCoreTelemetry.RecordTimeToFirstToken(time.GetElapsedTime(_stepStartedAt)); + } + + /// Marks the first text of this speech handed to the output. Only the first call records a reading. + public void MarkFirstSpeech() + { + if (userTurnEndedAt is long anchor && Claim(ref _firstSpeechMarked)) + { + AgentCoreTelemetry.RecordTimeToFirstSpeech(time.GetElapsedTime(anchor)); + } + } + + /// Marks the end of one step's text forwarding: LiveKit's stopped_speaking_at of that step. + public void MarkSpeechEnded() + { + _speechEndedAt = time.GetTimestamp(); + } + + /// Records the reply end at the last , for a speech that ended with nothing interrupting it. + public void EndReply() + { + if (userTurnEndedAt is long anchor && _speechEndedAt is long speechEndedAt && Claim(ref _replyEndMarked)) + { + AgentCoreTelemetry.RecordTimeToReplyEnd(time.GetElapsedTime(anchor, speechEndedAt)); + } + } + + private static bool Claim(ref int gate) + { + return Interlocked.Exchange(ref gate, 1) == 0; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/UserAwayOptions.cs b/src/AgentCore.AspNetCore/Voice/UserAwayOptions.cs new file mode 100644 index 00000000..a2784222 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/UserAwayOptions.cs @@ -0,0 +1,19 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_session.py:413 +// (user_away_timeout default), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, +// Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; LiveKit only emits +// user_state_changed and leaves "away" to the app. What to say is ours, per the owner's ruling on plan +// question 2: a fixed prompt, not a hook. + +namespace AgentCore.AspNetCore.Voice +{ + /// When the caller counts as away, and what the agent says once they do (plan Q2/owner ruling). + /// + /// How long both sides must stay / + /// before the caller is marked away. uses 15 s, LiveKit's default, + /// with "Are you still there?"; a document that omits userAway keeps it. A + /// , from an explicit userAway: null, + /// disables the timer. Marking the caller away never hangs up. + /// + /// What speaks once the caller is marked away. Never empty. + internal sealed record UserAwayOptions(TimeSpan Timeout, string Say); +} diff --git a/src/AgentCore.AspNetCore/Voice/UserAwayTimer.cs b/src/AgentCore.AspNetCore/Voice/UserAwayTimer.cs new file mode 100644 index 00000000..01be17b5 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/UserAwayTimer.cs @@ -0,0 +1,42 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// The one-shot countdown after which a counts the caller as away. + /// The clock the countdown runs on. + /// When and what to say, or to never arm. + /// Runs on a timer thread with the arm that fired. A throw from it ends the process. + internal sealed class UserAwayTimer(TimeProvider time, UserAwayOptions? options, Action onFire) + { + private ITimer? _timer; + + private long _arm; + + /// Gets when and what to say, or if the timer is disabled. + public UserAwayOptions? Options => options; + + /// Starts a new countdown, replacing any earlier one. Does nothing if disabled. + public void Arm() + { + Disarm(); + if (options is null) + { + return; + } + + long arm = ++_arm; + _timer = time.CreateTimer(_ => onFire(arm), null, options.Timeout, Timeout.InfiniteTimeSpan); + } + + /// Stops the countdown, if one is running. + public void Disarm() + { + _timer?.Dispose(); + _timer = null; + } + + /// Gets whether is the countdown still running. + public bool IsCurrent(long arm) + { + return _timer is not null && arm == _arm; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/UserState.cs b/src/AgentCore.AspNetCore/Voice/UserState.cs new file mode 100644 index 00000000..b2e25629 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/UserState.cs @@ -0,0 +1,19 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:309-324, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +namespace AgentCore.AspNetCore.Voice +{ + /// What the caller is doing right now. + internal enum UserState + { + /// An interim or final prompt is arriving. + Speaking, + + /// Not talking. + Listening, + + /// Silent long enough that userAway fires (wired in step 6). + Away, + } +} diff --git a/src/AgentCore.AspNetCore/Voice/UserStateChanged.cs b/src/AgentCore.AspNetCore/Voice/UserStateChanged.cs new file mode 100644 index 00000000..71aec76d --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/UserStateChanged.cs @@ -0,0 +1,13 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/events.py:309-324 +// (UserStateChangedEvent), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; raised as a plain event, +// not a session-wide pub/sub topic. + +namespace AgentCore.AspNetCore.Voice +{ + /// Raised each time 's user state actually changes. + /// The state before the change. + /// The state after the change. + /// When the change happened. + internal sealed record UserStateChanged(UserState OldState, UserState NewState, DateTimeOffset CreatedAt); +} diff --git a/src/AgentCore.AspNetCore/Voice/UserTurnHandler.cs b/src/AgentCore.AspNetCore/Voice/UserTurnHandler.cs new file mode 100644 index 00000000..74071949 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/UserTurnHandler.cs @@ -0,0 +1,101 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:2379-2436 +// (on_start_of_speech/on_end_of_speech, state part only), 2497-2565 (on_interim_transcript/ +// on_final_transcript, state part and interrupt), 2633-2709 (on_end_of_turn), 2712-2901 +// (_user_turn_completed_task/_impl), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 +// LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; Telnyx owns +// VAD/STT/endpointing, so an interim Utterance stands in for on_start_of_speech's state part and a final +// one for on_end_of_speech's, min-words/backchannel gates and the on_user_turn_completed hook are out +// (plan section 3), and "is this reply now outdated" is a sequence number rather than a task-identity +// comparison, since a .NET task cannot see its own enclosing Task from inside its own body. + +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// Turns a caller's interim and final prompts into session state and, on a final one, a reply. + /// The session whose states, scheduler and away timer this drives. + /// Starts the reply a committed turn answers with. + internal sealed class UserTurnHandler(VoiceSession session, VoiceActivity activity) + { + private readonly ILogger _logger = session.Logger; + + private long _latestSequence; + + /// An interim prompt arrived: the caller is mid-utterance. Never interrupts anything (plan Q3, B8). + public void OnInterimTranscript() + { + session.SetUserState(UserState.Speaking); + session.ClearUserSilence(); + } + + /// A final prompt arrived: the caller's turn is committed. Interrupts the current speech and + /// starts a reply, unless scheduling is paused or the current speech disallows interruptions. + /// What the caller said. + public void OnFinalTranscript(string transcript) + { + long endedAt = session.Time.GetTimestamp(); + + // on_final_transcript runs before on_end_of_speech (agent_activity.py:2552, 2433): the speech the + // caller talked over is interrupted before the silence below can release a step it holds. + activity.InterruptByFinalTranscript(); + + session.SetUserState(UserState.Listening); + session.MarkUserSilent(); + session.RefreshUserAwayOnFinalTranscript(); + + if (session.Scheduler.SchedulingPaused) + { + VoiceConversationLog.UserTurnSkippedSchedulingPaused(_logger); + return; + } + + Task? previous = session.Scheduler.UserTurnTask; + long sequence = Interlocked.Increment(ref _latestSequence); + Task turn = session.Scheduler.CreateSpeechTask(() => RunUserTurnAsync(previous, transcript, endedAt, sequence)); + session.Scheduler.SetUserTurnTask(turn); + } + + private async Task RunUserTurnAsync(Task? previousTurn, string transcript, long endedAt, long sequence) + { + if (previousTurn is not null) + { + try + { + await previousTurn.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + + IReadOnlyList background = session.Scheduler.InterruptBackgroundSpeeches(force: false); + await Task.WhenAll(background.Select(static speech => speech.WaitForPlayoutAsync())).ConfigureAwait(false); + + SpeechHandle? current = session.Scheduler.CurrentSpeech; + if (current is not null) + { + if (!current.AllowInterruptions) + { + VoiceConversationLog.UserTurnDroppedUninterruptibleSpeech(_logger); + return; + } + + _ = await current.Interrupt(source: InterruptionSource.UserTurn); + } + + // The close may have paused scheduling while this turn waited (agent_activity.py:2788-2796). + if (activity.TryGenerateReply(transcript, userTurnEndedAt: endedAt) is not { } reply) + { + VoiceConversationLog.UserTurnSkippedSchedulingPaused(_logger); + return; + } + + // A newer final prompt already arrived while this one was still working: this reply would + // answer a turn the caller has moved past (agent_activity.py:2896-2901). + if (Interlocked.Read(ref _latestSequence) != sequence) + { + _ = await reply.Interrupt(source: InterruptionSource.UserTurn); + } + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceActivity.cs b/src/AgentCore.AspNetCore/Voice/VoiceActivity.cs new file mode 100644 index 00000000..114da408 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceActivity.cs @@ -0,0 +1,258 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:653-658 +// (allow_interruptions), 1692-1766 (say), 1768-1866 (_generate_reply, LLM branch 1843-1865), 1935-1944 +// (interrupt's done future), 2310-2375 (_interrupt_by_audio_activity, reduced), 2531-2556 +// (on_final_transcript, its interrupt only), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; no realtime model, no tool +// resolution, no min-words gate, no false-interruption pause; the reply runs as one engine turn (plan 2.1). + +using AgentCore.Application.Ports; + +namespace AgentCore.AspNetCore.Voice +{ + /// Starts the agent's replies and routes a barge-in to the speech it cuts. + internal sealed class VoiceActivity + { + private static readonly IReadOnlyDictionary NoFillers = new Dictionary(); + + private readonly VoiceSession _session; + + private readonly bool? _allowInterruptions; + + private readonly CancellationToken _engineToken; + + private readonly IReadOnlyDictionary _fillers; + + private readonly TimeSpan _heardTextWait; + + private readonly List _replies = []; + + private IConversationPort _port; + + private PipelineReply? _lastHeard; + + private Task _engineRun = Task.CompletedTask; + + /// The session every speech plays in. + /// The conversation every reply's engine turn runs on. + /// Cancels every engine turn. Cancelled once, when the transport goes away. + /// + /// This agent's override of , or + /// to keep the session's. + /// + /// The filler each tool id opens while its call runs (plan 2.4), or for none. + /// + /// How long a reply a final prompt cut mid-step waits for the transport's report, or + /// for . + /// + public VoiceActivity( + VoiceSession session, + IConversationPort port, + CancellationToken engineToken, + bool? allowInterruptions = null, + IReadOnlyDictionary? fillers = null, + TimeSpan? heardTextWait = null) + { + _session = session; + _port = port; + _engineToken = engineToken; + _allowInterruptions = allowInterruptions; + _fillers = fillers ?? NoFillers; + _heardTextWait = heardTextWait ?? VoiceOptions.DefaultHeardTextWait; + } + + /// Gets whether a speech this activity starts may be interrupted, unless the call says otherwise. + public bool AllowInterruptions => _allowInterruptions ?? VoiceSession.DefaultAllowInterruptions; + + /// Gets a task that completes once the last engine turn this activity started has ended. It never faults. + public Task EngineRun + { + get + { + lock (_session.SessionLock) + { + return _engineRun; + } + } + } + + /// Points the replies started from now on at another conversation. A reply already started keeps its own. + /// The conversation every later reply's engine turn runs on. + public void Rebind(IConversationPort port) + { + lock (_session.SessionLock) + { + _port = port; + } + } + + /// Speaks fixed text through . + /// The text to speak. Never empty. + /// Overrides for this speech. + /// A handle to the speech. + public SpeechHandle Say(string text, bool? allowInterruptions = null) + { + return _session.Say(text, allowInterruptions ?? AllowInterruptions); + } + + /// Starts a reply to and queues its speech. + /// What the caller said. + /// Overrides for this speech. + /// + /// The at which the caller's final words arrived, or + /// when the reply answers no caller turn. + /// + /// A handle to the speech, done once every step was spoken or the speech was interrupted. + /// Scheduling is paused; no engine turn is started. + public SpeechHandle GenerateReply(string userInput, bool? allowInterruptions = null, long? userTurnEndedAt = null) + { + return TryGenerateReply(userInput, allowInterruptions, userTurnEndedAt) + ?? throw new InvalidOperationException( + "cannot schedule new speech, the speech scheduling is draining/pausing, the speech will be cancelled"); + } + + /// Starts a reply to and queues its speech, unless scheduling is paused. + /// What the caller said. + /// Overrides for this speech. + /// As takes it. + /// + /// A handle to the speech, or when scheduling is paused: then no engine turn starts, + /// as LiveKit skips the reply once the session is closing (agent_activity.py:2788-2796). + /// + public SpeechHandle? TryGenerateReply(string userInput, bool? allowInterruptions = null, long? userTurnEndedAt = null) + { + SpeechHandle handle = SpeechHandle.Create(_session.Time, _session.Logger, allowInterruptions ?? AllowInterruptions); + TurnMetrics metrics = new(_session.Time, userTurnEndedAt); + + // One hold of the session lock from the paused check to the queueing, so a close that pauses + // scheduling lands either before the engine turn starts or after the speech is queued. + lock (_session.SessionLock) + { + if (_session.Scheduler.SchedulingPaused) + { + return null; + } + + EngineReplyStream stream = EngineReplyStream.Start( + _port, userInput, _engineRun, handle, metrics, _session.Logger, _engineToken); + _engineRun = stream.Completion; + PipelineReply reply = new(handle, _session, stream, metrics, _fillers, OnFirstText, _heardTextWait, _engineToken); + _replies.Add(reply); + + _ = _session.Scheduler.CreateSpeechTask(reply.RunAsync, handle); + + // On the handle, not the task, as VoiceSession.Say does: it runs once the speech is marked done. + handle.AddDoneCallback(done => + { + lock (_session.SessionLock) + { + _ = _replies.Remove(reply); + } + + _session.OnSpeechDone(); + }); + + _session.Scheduler.ScheduleSpeech(handle, SpeechPriority.Normal); + } + + return handle; + } + + /// Interrupts the current speech for a final prompt, before the caller's silence can release a step it holds. + public void InterruptByFinalTranscript() + { + lock (_session.SessionLock) + { + if (_session.Scheduler.CurrentSpeech is not { IsInterrupted: false, AllowInterruptions: true } current) + { + return; + } + + _replies.Find(reply => reply.SpeechHandle == current)?.Hearing.ExpectBarge(); + _ = current.Interrupt(source: InterruptionSource.AudioActivity); + } + } + + /// Interrupts the speech a barge-in cut, and hands the transport's report to the turn the caller heard. + /// The text the caller heard, as the transport reported it. + /// How much of the reply played, as the transport reported it. + /// A task that completes once the interrupted speech is done and the latency is recorded. + public Task InterruptByAudioActivity(string heardText, TimeSpan playedDuration) + { + long detectedAt = _session.Time.GetTimestamp(); + SpeechHandle? interrupted; + PipelineReply? cutReply = null; + PipelineReply? heardEarlier = null; + lock (_session.SessionLock) + { + interrupted = BargeTargetLocked(); + if (interrupted is null && _replies.FindLast(reply => reply.Hearing.AwaitsBarge) is { } awaiting) + { + // A final prompt already interrupted it, and it waits for this report to settle its turn's text. + return awaiting.Hearing.RecordBarge(heardText, playedDuration) + ? RecordBargeInWhenDone(awaiting.SpeechHandle, detectedAt) + : Task.CompletedTask; + } + + if (interrupted is null) + { + heardEarlier = _lastHeard is { SpeechHandle.IsDone: true } last ? last : null; + } + else + { + cutReply = _replies.Find(reply => reply.SpeechHandle == interrupted); + _ = cutReply?.Hearing.RecordBarge(heardText, playedDuration); + _ = interrupted.Interrupt(source: InterruptionSource.AudioActivity); + } + } + + // Before returning, so the engine holds the cut before the transport's next frame is read. The speech's + // own teardown runs later on another thread, and a turn that finished first would commit unheard words. + if (interrupted is null) + { + heardEarlier?.Hearing.CutHeard(heardText, playedDuration); + return Task.CompletedTask; + } + + _ = cutReply?.Hearing.CutInterrupted(); + + return RecordBargeInWhenDone(interrupted, detectedAt); + } + + /// Records the barge-in's latency once the speech it interrupted is done, as LiveKit's interrupt() future resolves. + /// A task that completes once the latency is recorded. + private Task RecordBargeInWhenDone(SpeechHandle interrupted, long detectedAt) + { + TaskCompletionSource recorded = new(TaskCreationOptions.RunContinuationsAsynchronously); + interrupted.AddDoneCallback(done => + { + TurnMetrics.RecordBargeIn(_session.Time, detectedAt); + _ = recorded.TrySetResult(); + }); + return recorded.Task; + } + + private void OnFirstText(PipelineReply reply) + { + lock (_session.SessionLock) + { + _lastHeard = reply; + } + } + + private SpeechHandle? BargeTargetLocked() + { + SpeechHandle? current = _session.Scheduler.CurrentSpeech; + PipelineReply? currentReply = current is null ? null : _replies.Find(reply => reply.SpeechHandle == current); + if (current is not null && (currentReply is null || currentReply.Hearing.HasForwardedText)) + { + return !current.IsInterrupted && current.AllowInterruptions ? current : null; + } + + PipelineReply? hearing = _replies.FindLast(reply => reply.Hearing.HasForwardedText); + return hearing?.SpeechHandle is { IsDone: false, IsInterrupted: false, AllowInterruptions: true } speech + ? speech + : null; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceConversationLog.cs b/src/AgentCore.AspNetCore/Voice/VoiceConversationLog.cs new file mode 100644 index 00000000..c6d3dbda --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceConversationLog.cs @@ -0,0 +1,176 @@ +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Every line and the speech types under it write. + /// + internal static partial class VoiceConversationLog + { + /// A prompt arrived before the conversation started, logged once for the conversation. + /// The logger of the connection that owns the loop. + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "the transport sent a prompt before the conversation started. Every prompt before the start is dropped.")] + public static partial void PromptBeforeSetup(ILogger logger); + + /// The transport reported a barge-in. Never the words the caller said. + /// The logger of the connection that owns the loop. + /// The id of the conversation. + [LoggerMessage( + EventId = 12, + Level = LogLevel.Information, + Message = "the transport reported a barge-in on conversation {ConversationId}.")] + public static partial void InterruptReceived(ILogger logger, string conversationId); + + /// The conversation started a second time on one transport, logged once for the conversation. + /// The logger of the connection that owns the loop. + /// The id of the conversation the first setup frame started. + [LoggerMessage( + EventId = 19, + Level = LogLevel.Warning, + Message = "the transport started conversation {ConversationId} a second time. The first session is " + + "released and the new one replaces it.")] + public static partial void SecondSetupFrame(ILogger logger, string conversationId); + + /// Closing the audit chain of a conversation faulted, so that call has no conversation.ended event. + /// The logger of the connection that owns the loop. + /// The id of the conversation whose chain did not close. + /// The cause. + [LoggerMessage( + EventId = 20, + Level = LogLevel.Error, + Message = "the audit chain of conversation {ConversationId} could not be closed, so it has no conversation.ended event.")] + public static partial void ConversationEndFaulted(ILogger logger, string conversationId, Exception exception); + + /// The store could not be told that a conversation is still being had. + /// The logger. + /// The id of the conversation. + /// The cause. + [LoggerMessage( + EventId = 23, + Level = LogLevel.Warning, + Message = "the store could not be read for conversation {ConversationId}, so that call may reach its idle timeout and be dropped while it is still running.")] + public static partial void SessionTouchFaulted(ILogger logger, string conversationId, Exception exception); + + /// An interrupted speech did not finish within , so its tasks were cancelled. + /// The logger the speech was created with. + /// The id of the speech. + /// The timeout that passed, in seconds. + [LoggerMessage( + EventId = 24, + Level = LogLevel.Error, + Message = "speech {SpeechId} not done in time after interruption ({TimeoutSeconds} s), cancelling the speech arbitrarily.")] + public static partial void SpeechNotDoneAfterInterruption(ILogger logger, string speechId, double timeoutSeconds); + + /// A callback waiting for a speech to be done threw. + /// The logger the speech was created with. + /// The id of the speech. + /// The cause. + [LoggerMessage( + EventId = 25, + Level = LogLevel.Warning, + Message = "error in a done callback of speech {SpeechId}.")] + public static partial void SpeechDoneCallbackFaulted(ILogger logger, string speechId, Exception exception); + + /// An interruption stopped at a queued speech that disallows interruptions. + /// The logger of the voice session. + /// The id of the queued speech. + [LoggerMessage( + EventId = 26, + Level = LogLevel.Warning, + Message = "a queued speech {SpeechId} does not allow interruptions and will play after the interruption, " + + "use interrupt(force=True) to interrupt it as well")] + public static partial void QueuedSpeechNotInterruptible(ILogger logger, string speechId); + + /// A speech was scheduled after the scheduling loop ended, so it was cancelled. + /// The logger of the voice session. + /// The id of the speech. + [LoggerMessage( + EventId = 27, + Level = LogLevel.Warning, + Message = "attempting to schedule speech {SpeechId}, but the scheduling task is not running, the speech will be cancelled")] + public static partial void SchedulingTaskNotRunning(ILogger logger, string speechId); + + /// A speech task faulted. + /// The logger of the voice session. + /// The cause. + [LoggerMessage( + EventId = 28, + Level = LogLevel.Error, + Message = "error in a speech task.")] + public static partial void SpeechTaskFaulted(ILogger logger, Exception exception); + + /// The speech scheduling loop faulted, so no further speech plays. + /// The logger of the voice session. + /// The cause. + [LoggerMessage( + EventId = 29, + Level = LogLevel.Error, + Message = "error in the speech scheduling task.")] + public static partial void SchedulingLoopFaulted(ILogger logger, Exception exception); + + /// A committed user turn arrived while the current speech disallows interruptions, so it was dropped. + /// The logger of the voice session. + [LoggerMessage( + EventId = 30, + Level = LogLevel.Warning, + Message = "skipping reply to user input, current speech generation cannot be interrupted")] + public static partial void UserTurnDroppedUninterruptibleSpeech(ILogger logger); + + /// A committed user turn arrived while speech scheduling is paused, so it was skipped. + /// The logger of the voice session. + [LoggerMessage( + EventId = 31, + Level = LogLevel.Warning, + Message = "skipping user input, speech scheduling is paused")] + public static partial void UserTurnSkippedSchedulingPaused(ILogger logger); + + /// A filler's own say raised, so no further filler will fire for that tool call. + /// The logger of the voice session. + /// The cause. + [LoggerMessage( + EventId = 32, + Level = LogLevel.Error, + Message = "filler stopped on an error, no further filler will play for this tool call.")] + public static partial void FillerFaulted(ILogger logger, Exception exception); + + /// The caller went away as the session closed, so the away prompt was not spoken. + /// The logger of the voice session. + /// The cause. + [LoggerMessage( + EventId = 33, + Level = LogLevel.Information, + Message = "the away prompt was not spoken, speech scheduling had already stopped for the close.")] + public static partial void UserAwayPromptNotScheduled(ILogger logger, Exception exception); + + /// The transport named a conversation another entry holds live, so nothing was opened. + /// The logger of the connection that owns the loop. + /// The id of the conversation the transport named. + /// The entry the transport arrived on. + [LoggerMessage( + EventId = 36, + Level = LogLevel.Warning, + Message = "conversation {ConversationId} is in use by another entry, so entry {Entry} refused the transport.")] + public static partial void ConversationInUse(ILogger logger, string conversationId, string entry); + + /// The engine refused a cut, so the turn's history stays as it was. + /// The logger of the voice session. + /// The turn the cut named. + [LoggerMessage( + EventId = 34, + Level = LogLevel.Information, + Message = "the cut of turn {TurnIndex} was refused: the turn was already cut, or a later turn had started, so its history is unchanged.")] + public static partial void CutRefused(ILogger logger, int turnIndex); + + /// The engine refused a recut, so the turn keeps the cut it took. + /// The logger of the voice session. + /// The turn the recut named. + [LoggerMessage( + EventId = 35, + Level = LogLevel.Information, + Message = "the recut of turn {TurnIndex} was refused: the turn took no cut, or a later turn had started, so it keeps the cut it took.")] + public static partial void RecutRefused(ILogger logger, int turnIndex); + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs b/src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs new file mode 100644 index 00000000..fedfeac6 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceConversationLoop.cs @@ -0,0 +1,284 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Feeds one voice conversation's inbound events to the turn-taking classes: user turns, replies, + /// barge-ins, and the away prompt. + /// + /// The one session owner, shared by every entry. + /// The entry the URL named. Every open and lookup this loop makes names it. + /// Where every reply goes. + /// Watches every task this loop starts. + /// The clock the host bound. + /// The logger of the connection that owns this loop. + /// How long a replaced session may take to close. + /// Cancelled once, for any reason the transport is going away. + /// The away prompt and the per-tool filler, or for . + internal sealed class VoiceConversationLoop( + IConversationSessions sessions, + string entry, + IConversationOutputPort output, + ConnectionTaskObserver observer, + TimeProvider time, + ILogger logger, + TimeSpan closeTimeout, + CancellationToken connectionToken, + VoiceOptions? options = null) + { + private readonly VoiceOptions _options = options ?? VoiceOptions.Default; + + private readonly VoiceSession _voice = new(output, time, logger, (options ?? VoiceOptions.Default).UserAway); + + private volatile ConversationSession? _session; + + private volatile VoiceActivity? _activity; + + private UserTurnHandler? _userTurns; + + private bool _loggedPromptBeforeSetup; + + private bool _loggedSecondSetup; + + /// Gets the id of the conversation this loop answers, or before it started. + public string? ConversationId => _session?.ConversationId; + + /// Gets whether a conversation has started on this loop. + public bool HasStarted => _session is not null; + + /// + /// Gets whether the loop stopped because the conversation the transport named is held live by another entry. + /// + public bool WasRefused { get; private set; } + + /// Reads every inbound event, in order, until the stream ends or an open is refused. + /// The conversation's inbound events. + /// A task that completes when the stream ends, or once is set. + public async Task RunAsync(IAsyncEnumerable inputs) + { + await foreach (ConversationInput input in inputs.ConfigureAwait(false)) + { + switch (input) + { + case ConversationInput.Started started: + if (!await StartConversationAsync(started.ConversationId).ConfigureAwait(false)) + { + return; + } + + break; + + case ConversationInput.Utterance { IsFinal: true } utterance: + await OnFinalUtteranceAsync(utterance.Text).ConfigureAwait(false); + break; + + case ConversationInput.Utterance { Text.Length: > 0 }: + _userTurns?.OnInterimTranscript(); + break; + + case ConversationInput.Barge barge: + Interrupt(barge); + break; + + default: + // A keypress, or an empty interim transcript. Neither is part of turn-taking. + break; + } + } + } + + /// Tears the conversation's speech down: LiveKit's session close order, then the engine turns behind it. + /// A task that completes once no speech is left running and every engine turn has ended. + public async Task DrainAsync() + { + await _voice.CloseAsync().ConfigureAwait(false); + + if (_activity is { } activity) + { + await activity.EngineRun.ConfigureAwait(false); + } + } + + /// Closes the audit chain and the session of the conversation, if one started. + /// Why the conversation ended. + /// A task that completes once the session is closed or its close timed out. + public async Task EndAsync(ConversationEndReason reason) + { + if (_session is not { } session) + { + return; + } + + // The id may have moved on: this loop's own session unloaded, and a later caller reopened it. + // Ending or closing that newer session here would answer this loop's hang-up with someone else's + // call. A store that cannot be read falls back to today's behaviour, on this loop's own session. + ConversationSession? current; + try + { + current = await sessions.TryGetAsync(entry, session.ConversationId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted(logger, session.ConversationId, fault)); + current = session; + } + + if (current is not null && !ReferenceEquals(current, session)) + { + return; + } + + try + { + _ = session.EndConversation(reason); + } + catch (Exception fault) + { + ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.ConversationEndFaulted( + logger, + session.ConversationId, + fault)); + } + + await observer + .ObserveAsync(CloseSessionAsync(session.ConversationId), ConnectionTaskKind.SessionClose, closeTimeout) + .ConfigureAwait(false); + } + + private async Task CloseSessionAsync(string conversationId) + { + await sessions.CloseAsync(entry, conversationId, CancellationToken.None).ConfigureAwait(false); + } + + /// when another entry holds the conversation live, so nothing was opened. + private async Task StartConversationAsync(string conversationId) + { + if (_session is { } replaced) + { + if (!_loggedSecondSetup) + { + _loggedSecondSetup = true; + VoiceConversationLog.SecondSetupFrame(logger, replaced.ConversationId); + } + + // The id may have moved on: replaced already unloaded, and a later caller reopened it. Closing + // that newer session here would tear down a call this loop never held. A store that cannot be + // read falls back to today's behaviour, on this loop's own replaced session. + ConversationSession? current; + try + { + current = await sessions.TryGetAsync(entry, replaced.ConversationId, connectionToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted(logger, replaced.ConversationId, fault)); + current = replaced; + } + + if (current is null || ReferenceEquals(current, replaced)) + { + await observer + .ObserveAsync( + CloseSessionAsync(replaced.ConversationId), + ConnectionTaskKind.SessionClose, + closeTimeout) + .ConfigureAwait(false); + } + + // Closed already: a refused reopen below must leave EndAsync nothing of this loop's own to end. + _session = null; + } + + ConversationSession session; + try + { + session = await sessions.GetOrOpenAsync(entry, conversationId, state: null, connectionToken).ConfigureAwait(false); + } + catch (ConversationInUseException) + { + WasRefused = true; + VoiceConversationLog.ConversationInUse(logger, conversationId, entry); + return false; + } + + _session = session; + + if (_activity is { } activity) + { + activity.Rebind(session); + return true; + } + + VoiceActivity started = new( + _voice, + session, + connectionToken, + fillers: _options.Fillers, + heardTextWait: _options.HeardTextWait); + _userTurns = new UserTurnHandler(_voice, started); + _activity = started; + _voice.Start(); + return true; + } + + private async Task OnFinalUtteranceAsync(string text) + { + if (_userTurns is not { } userTurns) + { + // Log once for the conversation, not once for the utterance. + if (!_loggedPromptBeforeSetup) + { + _loggedPromptBeforeSetup = true; + VoiceConversationLog.PromptBeforeSetup(logger); + } + + return; + } + + await KeepSessionAliveAsync().ConfigureAwait(false); + + userTurns.OnFinalTranscript(text); + } + + /// Tells the store this conversation is still being had. + /// A task that completes once the store has been read. + private async Task KeepSessionAliveAsync() + { + if (_session is not { } session) + { + return; + } + + try + { + _ = await sessions.TryGetAsync(entry, session.ConversationId, connectionToken).ConfigureAwait(false); + } + catch (Exception fault) when (fault is not OperationCanceledException) + { + ConnectionTaskObserver.SafeLog(() => VoiceConversationLog.SessionTouchFaulted( + logger, session.ConversationId, fault)); + } + } + + private void Interrupt(ConversationInput.Barge barge) + { + if (_activity is not { } activity || _session is not { } session) + { + return; + } + + // Stopped first: the interrupted speech still flushes its step as it tears down, and that closing + // token must meet a reply already stopped rather than reach the transport after the interrupt. + _ = observer.ObserveAsync(output.StopAsync(connectionToken).AsTask(), ConnectionTaskKind.WriteLoop); + + // The latency is recorded once every speech it interrupted is done; nothing here waits for that. + _ = activity.InterruptByAudioActivity(barge.HeardText, barge.PlayedDuration); + + VoiceConversationLog.InterruptReceived(logger, session.ConversationId); + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceOptions.cs b/src/AgentCore.AspNetCore/Voice/VoiceOptions.cs new file mode 100644 index 00000000..0df8bf81 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceOptions.cs @@ -0,0 +1,22 @@ +namespace AgentCore.AspNetCore.Voice +{ + /// What one voice conversation does on its own: the away prompt and the per-tool filler. + /// When the caller counts as away and what the agent says then, or to disable it. + /// The filler each tool id opens while its call runs. Empty means no filler. + internal sealed record VoiceOptions(UserAwayOptions? UserAway, IReadOnlyDictionary Fillers) + { + /// The budget gives a transport to report what was heard: 2 s. + public static readonly TimeSpan DefaultHeardTextWait = TimeSpan.FromSeconds(2); + + /// Gets the ruled defaults: away after 15 s with "Are you still there?", and no filler. + public static VoiceOptions Default { get; } = new( + new UserAwayOptions(TimeSpan.FromSeconds(15), "Are you still there?"), + new Dictionary()); + + /// + /// Gets how long a reply a final prompt cut mid-step waits for the transport's report of what the caller + /// heard before it cuts its turn to the text it forwarded. Not in the agent document. + /// + public TimeSpan HeardTextWait { get; init; } = DefaultHeardTextWait; + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceOptionsBinder.cs b/src/AgentCore.AspNetCore/Voice/VoiceOptionsBinder.cs new file mode 100644 index 00000000..e4d71634 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceOptionsBinder.cs @@ -0,0 +1,103 @@ +using System.Globalization; +using System.Text.Json; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; + +namespace AgentCore.AspNetCore.Voice +{ + /// + /// Turns providers.conversation's userAway and filler into a . + /// + internal static class VoiceOptionsBinder + { + private const string ConversationPointer = "/providers/conversation"; + + /// Builds the voice options a providers.conversation block asks for. + /// The bound providers.conversation block. + /// The options, with kept for whatever the document leaves unwritten. + /// + /// A timeoutSeconds, delaySeconds, or intervalSeconds is not more than zero, or + /// would be refused at run time by Task.Delay, CancelAfter, or Task.WaitAsync. + /// The pointer names the exact field. + /// + internal static VoiceOptions Build(ConversationProviderConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + return new VoiceOptions(BuildUserAway(configuration.UserAway), BuildFillers(configuration.Filler)); + } + + private static UserAwayOptions? BuildUserAway(JsonElement userAway) + { + return userAway.ValueKind switch + { + JsonValueKind.Undefined => VoiceOptions.Default.UserAway, + JsonValueKind.Null => null, + JsonValueKind.Object => new UserAwayOptions( + ToTimeSpan( + Pointer("userAway", "timeoutSeconds"), + "userAway.timeoutSeconds", + userAway.GetProperty("timeoutSeconds").GetDouble()), + userAway.GetProperty("say").GetString()!), + _ => throw new InvalidOperationException( + $"unreachable: check 1 restricts providers.conversation.userAway to an object or null, and it is {userAway.ValueKind}."), + }; + } + + private static IReadOnlyDictionary BuildFillers( + IReadOnlyDictionary filler) + { + if (filler.Count == 0) + { + return VoiceOptions.Default.Fillers; + } + + Dictionary built = new(filler.Count, StringComparer.Ordinal); + foreach ((string toolId, VoiceFillerConfiguration one) in filler) + { + string field = $"filler.{toolId}"; + + built[toolId] = new FillerOptions( + one.Say, + ToTimeSpan(Pointer("filler", toolId, "delaySeconds"), $"{field}.delaySeconds", one.DelaySeconds), + one.IntervalSeconds is { } interval + ? ToTimeSpan(Pointer("filler", toolId, "intervalSeconds"), $"{field}.intervalSeconds", interval) + : null, + one.MaxSteps); + } + + return built; + } + + /// Turns whole or fractional seconds from the document into the span a bounded wait accepts. + /// The JSON Pointer to the field, for the error. + /// The dotted providers.conversation field name, for the error's message. + /// The seconds the document wrote. + private static TimeSpan ToTimeSpan(string pointer, string field, double seconds) + { + return seconds > 0 && seconds <= BoundedTimerLimits.MaximumBoundedDelay.TotalSeconds + ? TimeSpan.FromSeconds(seconds) + : throw new ConfigurationLoadException(new ConfigurationError + { + Pointer = pointer, + Message = string.Create( + CultureInfo.InvariantCulture, + $"providers.conversation.{field} is {seconds} seconds. It must be more than 0 and at most " + + $"{BoundedTimerLimits.MaximumBoundedDelay}, the longest Task.Delay, CancelAfter, and " + + $"Task.WaitAsync accept at run time."), + Check = ConfigurationCheck.ValueRange, + }); + } + + private static string Pointer(params ReadOnlySpan segments) + { + string pointer = ConversationPointer; + foreach (string segment in segments) + { + pointer = ConfigurationError.AppendPointer(pointer, segment); + } + + return pointer; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceSession.cs b/src/AgentCore.AspNetCore/Voice/VoiceSession.cs new file mode 100644 index 00000000..d0bcbceb --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceSession.cs @@ -0,0 +1,383 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_session.py:1530-1562 +// (say), 1634-1651 (interrupt), 1741-1764 (wait_for_idle), 1159-1160 (start), 1349-1386 +// (_teardown_activity, no AgentTask/AMD/audio part), 2020-2044 (away timer), 2120-2181 +// (_update_agent_state, state and away-timer parts), 2183-2244 (_update_user_state, state and +// away-timer parts), 2254-2267 (_user_input_transcribed); livekit-agents/livekit/agents/voice/ +// agent_activity.py: 1692-1766 (say, text branch 1751-1765), 2070-2133 (wait_for_idle, wait_for_agent +// branch), 3086-3098 (_no_pending_speech, _on_pipeline_reply_done); livekit-agents/livekit/agents/ +// voice/turn.py: 190-198 (_INTERRUPTION_DEFAULTS["enabled"]); speech_handle.py:349 +// (_user_silence_event.set()), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 +// LiveKit, Inc. Licensed under the Apache License, Version 2.0. Modified: translated to C#; one +// activity per conversation, so AgentSession and AgentActivity collapse into one type; no handoff, no +// drain, no realtime branch; what "away" says is ours, not LiveKit's (plan Q2/owner ruling); input-side +// wiring is UserTurnHandler.cs. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Voice +{ + /// One voice conversation's speech: what the agent says, and when. + [SuppressMessage( + "Design", + "CA1001:Types that own disposable fields should be disposable", + Justification = "The one disposable field is a CancellationTokenSource with no timer and no linked " + + "parent, so it holds nothing to release; CloseAsync cancels it.")] + internal sealed class VoiceSession + { + /// LiveKit's default (turn.py _INTERRUPTION_DEFAULTS["enabled"]). The only + /// override is answering-machine detection, which is out of scope (audio-only). + internal const bool DefaultAllowInterruptions = true; + + private readonly AsyncEvent _userSilence = new(); + + private readonly CancellationTokenSource _closedCancellation = new(); + + private readonly UserAwayTimer _userAwayTimer; + + private AgentState _agentState = AgentState.Listening; + + private UserState _userState = UserState.Listening; + + /// Where every reply goes. + /// The clock the interruption backstop and the queue wait run on. + /// Where scheduling warnings and speech task faults are reported. + /// + /// When and what to say once the caller has gone quiet, or to disable the + /// timer (plan Q2/owner ruling). The config/schema surface for this is plan step 8. + /// + public VoiceSession(IConversationOutputPort output, TimeProvider time, ILogger logger, UserAwayOptions? userAway = null) + { + Output = output; + Time = time; + Logger = logger; + _userAwayTimer = new UserAwayTimer(time, userAway, OnUserAwayTimer); + Scheduler = new SpeechScheduler(SessionLock, logger); + _userSilence.Set(); + Scheduler.ResumeScheduling(); + } + + /// Raised each time actually changes. + public event Action? AgentStateChanged; + + /// Raised each time actually changes. + public event Action? UserStateChanged; + + /// Gets what the agent is doing right now. + public AgentState AgentState + { + get + { + lock (SessionLock) + { + return _agentState; + } + } + } + + /// Gets what the caller is doing right now. + public UserState UserState + { + get + { + lock (SessionLock) + { + return _userState; + } + } + } + + /// Gets whether a speech is waiting on its own tools with its words already spoken. + internal bool HasBackgroundSpeeches => Scheduler.HasBackgroundSpeeches; + + /// Gets the scheduler that plays this session's speeches. + internal SpeechScheduler Scheduler { get; } + + /// Gets the lock every state read-modify-write of this session is made under. + internal Lock SessionLock { get; } = new(); + + /// Gets where every speech's text goes. + internal IConversationOutputPort Output { get; } + + /// Gets the clock the session runs on. + internal TimeProvider Time { get; } + + /// Gets where the session's speeches report. + internal ILogger Logger { get; } + + /// Speaks fixed text: a notice, a filler, or anything not generated by a model step. + /// The text to speak. Never empty. + /// + /// Whether a committed caller turn or a barge-in may cut this speech. Defaults to + /// . + /// + /// A handle to the speech, done once it has been spoken or dropped. + public SpeechHandle Say(string text, bool? allowInterruptions = null) + { + SpeechHandle handle = SpeechHandle.Create(Time, Logger, allowInterruptions ?? DefaultAllowInterruptions); + _ = Scheduler.CreateSpeechTask( + () => SayReply.RunAsync(handle, this, Output, SingleFragment(text), handle.TaskCancellationToken), + handle); + + // Registered on the handle, not on the task: it must run once every task tied to the speech + // has finished and the speech itself is marked done (agent_activity.py's _on_pipeline_reply_done + // relies on the same ordering, guaranteed there by asyncio's done-callback registration order). + handle.AddDoneCallback(_ => OnSpeechDone()); + Scheduler.ScheduleSpeech(handle, SpeechPriority.Normal); + return handle; + } + + /// Interrupts the current speech, the background speeches, and the queue up to the first + /// speech that disallows interruptions. + /// Interrupt even a speech that disallows interruptions. + /// A task that completes once every speech this interrupted is done. + public Task Interrupt(bool force = false) + { + return Scheduler.Interrupt(force); + } + + /// Waits until no speech is current or queued. + /// Cancels this wait only, never a speech. + /// The session closed while this call waited. + public async Task WaitForIdleAsync(CancellationToken cancellationToken = default) + { + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _closedCancellation.Token); + + try + { + await Scheduler.WaitForIdleAsync(linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_closedCancellation.IsCancellationRequested + && !cancellationToken.IsCancellationRequested) + { + throw new VoiceSessionClosedException(); + } + + // The scheduler can settle to idle and close race one another; recheck once more before + // returning, as LiveKit's wait_for_idle does right after its own loop exits. + if (_closedCancellation.IsCancellationRequested) + { + throw new VoiceSessionClosedException(); + } + } + + /// LiveKit's session start (agent_session.py:1159-1160): the agent starts listening, which + /// arms the away window. + internal void Start() + { + lock (SessionLock) + { + if (_agentState == AgentState.Listening && _userState == UserState.Listening) + { + ArmUserAwayTimerLocked(); + } + } + } + + /// LiveKit's _teardown_activity without drain (agent_session.py:1349-1386): close, + /// force-interrupt every speech, stop scheduling once every speech task has ended, and wait out the + /// current speech. + /// A task that completes once no speech of this session is left running. + internal async Task CloseAsync() + { + MarkClosed(); + + await Scheduler.Interrupt(force: true).ConfigureAwait(false); + await Scheduler.PauseSchedulingAsync().ConfigureAwait(false); + + if (Scheduler.CurrentSpeech is { } current) + { + _ = await current; + } + + // agent_session.py:1329: the teardown above hands the floor back, and a hand-back arms the timer. + lock (SessionLock) + { + _userAwayTimer.Disarm(); + } + } + + /// Marks the session closed. Every current and future call + /// raises , and the away timer stops. + internal void MarkClosed() + { + _closedCancellation.Cancel(); + lock (SessionLock) + { + _userAwayTimer.Disarm(); + } + } + + /// Sets the agent state, and raises if it actually changed. + internal void SetAgentState(AgentState state) + { + AgentStateChanged change; + lock (SessionLock) + { + if (_agentState == state) + { + return; + } + + change = new AgentStateChanged(_agentState, state, Time.GetUtcNow()); + _agentState = state; + + if (state == AgentState.Listening && _userState == UserState.Listening) + { + ArmUserAwayTimerLocked(); + } + else + { + _userAwayTimer.Disarm(); + } + } + + AgentStateChanged?.Invoke(change); + } + + /// Sets the user state, and raises if it actually changed. + internal void SetUserState(UserState state) + { + UserStateChanged? change; + lock (SessionLock) + { + change = SetUserStateLocked(state); + } + + if (change is not null) + { + UserStateChanged?.Invoke(change); + } + } + + /// Waits until no interim prompt is in flight. + internal Task WaitForUserSilenceAsync(CancellationToken cancellationToken = default) + { + return _userSilence.WaitAsync(cancellationToken); + } + + /// Marks the caller as mid-utterance: 's + /// on_start_of_speech half (agent_activity.py:2392). + internal void ClearUserSilence() + { + _userSilence.Clear(); + } + + /// Marks the caller as not mid-utterance: 's + /// on_end_of_speech half (agent_activity.py:2433). + internal void MarkUserSilent() + { + _userSilence.Set(); + } + + /// LiveKit's _user_input_transcribed (agent_session.py:2254-2267), the away-timer + /// part: a final transcript resets the away window while listening, or cancels a stray "away". + internal void RefreshUserAwayOnFinalTranscript() + { + bool backToListening; + lock (SessionLock) + { + if (_userState == UserState.Speaking) + { + return; + } + + backToListening = _userState == UserState.Away; + if (!backToListening && _userState == UserState.Listening && _agentState == AgentState.Listening) + { + ArmUserAwayTimerLocked(); + } + } + + if (backToListening) + { + SetUserState(UserState.Listening); + } + } + + /// LiveKit's _on_pipeline_reply_done: once no speech is left, hand the floor back. + internal void OnSpeechDone() + { + if (Scheduler.NoPendingSpeech) + { + SetAgentState(HasBackgroundSpeeches ? AgentState.Thinking : AgentState.Listening); + } + } + + /// Arms the away timer, unless the session is closed or a speech still waits on its own tools + /// (LiveKit's _RunningTasks guard, agent_session.py:2025-2027). Called under _lock. + private void ArmUserAwayTimerLocked() + { + if (Scheduler.HasBackgroundSpeeches || _closedCancellation.IsCancellationRequested) + { + _userAwayTimer.Disarm(); + return; + } + + _userAwayTimer.Arm(); + } + + /// Sets the user state and arms or disarms the away timer to match. Called under _lock. + /// The change to raise once the lock is released, or if nothing changed. + private UserStateChanged? SetUserStateLocked(UserState state) + { + if (_userState == state) + { + return null; + } + + UserStateChanged change = new(_userState, state, Time.GetUtcNow()); + _userState = state; + + if (state == UserState.Listening && _agentState == AgentState.Listening) + { + ArmUserAwayTimerLocked(); + } + else + { + _userAwayTimer.Disarm(); + } + + return change; + } + + /// Runs on a timer thread, where a throw ends the process. Only the current arm of an open + /// session acts; see . + private void OnUserAwayTimer(long arm) + { + UserStateChanged? change; + lock (SessionLock) + { + if (_closedCancellation.IsCancellationRequested || !_userAwayTimer.IsCurrent(arm)) + { + return; + } + + change = SetUserStateLocked(UserState.Away); + } + + if (change is not null) + { + UserStateChanged?.Invoke(change); + } + + // LiveKit only emits user_state_changed and leaves "away" to the app; this is our own + // answer to plan Q2, not a LiveKit behaviour. + try + { + _ = Say(_userAwayTimer.Options!.Say); + } + catch (InvalidOperationException ex) + { + // A close can pause scheduling between the check above and this call. + VoiceConversationLog.UserAwayPromptNotScheduled(Logger, ex); + } + } + + private static async IAsyncEnumerable SingleFragment(string text) + { + yield return text; + } + } +} diff --git a/src/AgentCore.AspNetCore/Voice/VoiceSessionClosedException.cs b/src/AgentCore.AspNetCore/Voice/VoiceSessionClosedException.cs new file mode 100644 index 00000000..f212ae54 --- /dev/null +++ b/src/AgentCore.AspNetCore/Voice/VoiceSessionClosedException.cs @@ -0,0 +1,11 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:2097-2098, +// 2132-2133 (the closed/closing check inside wait_for_idle, raising ActivityClosedError), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; one VoiceSession per +// conversation, so the flag belongs to the session rather than to a separate activity object. + +namespace AgentCore.AspNetCore.Voice +{ + /// The session closed, or was already closing, while something waited on it. + internal sealed class VoiceSessionClosedException() : Exception("the voice session is closing."); +} diff --git a/src/AgentCore.Domain/Audit/AuditEventKind.cs b/src/AgentCore.Domain/Audit/AuditEventKind.cs index ebb7bd25..a768b335 100644 --- a/src/AgentCore.Domain/Audit/AuditEventKind.cs +++ b/src/AgentCore.Domain/Audit/AuditEventKind.cs @@ -26,5 +26,8 @@ public enum AuditEventKind /// A caller replaced an earlier message, so the turns after it were withdrawn. TurnSuperseded = 7, + + /// A turn was refused or dropped, so none of its words were kept. + TurnRefused = 8, } } diff --git a/src/AgentCore.Domain/Audit/AuditEventKinds.cs b/src/AgentCore.Domain/Audit/AuditEventKinds.cs index 55e79f44..27a3c22b 100644 --- a/src/AgentCore.Domain/Audit/AuditEventKinds.cs +++ b/src/AgentCore.Domain/Audit/AuditEventKinds.cs @@ -20,6 +20,7 @@ public static string ToToken(AuditEventKind kind) AuditEventKind.ConversationEnded => "conversation.ended", AuditEventKind.PromptFlagged => "prompt.flagged", AuditEventKind.TurnSuperseded => "turn.superseded", + AuditEventKind.TurnRefused => "turn.refused", _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "The audit event vocabulary is closed, and this value is not in it."), }; } @@ -39,6 +40,7 @@ public static bool TryParse(string? token, out AuditEventKind kind) case "conversation.ended": kind = AuditEventKind.ConversationEnded; return true; case "prompt.flagged": kind = AuditEventKind.PromptFlagged; return true; case "turn.superseded": kind = AuditEventKind.TurnSuperseded; return true; + case "turn.refused": kind = AuditEventKind.TurnRefused; return true; default: kind = default; return false; } } diff --git a/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs b/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs index 98c00217..a7e5c88c 100644 --- a/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs +++ b/src/AgentCore.Domain/Audit/AuditPayloadKeys.cs @@ -73,5 +73,15 @@ public static class AuditPayloadKeys /// The highest turn index an edit withdrew, included. /// public const string WithdrewThroughTurnIndex = "withdrewThroughTurnIndex"; + + /// + /// On turn.refused: why the turn was refused. busy: another turn held the conversation past the + /// wait limit. conflict: the store refused the words, because another session saved that turn first. + /// gone: the client left while the turn waited to start. running: this session was still running + /// a turn. dropped: the caller let go of the turn before it read the reply. A busy or + /// gone refusal carries no turn index: the turn never read the conversation, so the index it would + /// have taken is not known. + /// + public const string RefusedReason = "refusedReason"; } } diff --git a/src/AgentCore.Domain/PublicAPI.Unshipped.txt b/src/AgentCore.Domain/PublicAPI.Unshipped.txt index 3e7ffc36..f8abfe28 100644 --- a/src/AgentCore.Domain/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Domain/PublicAPI.Unshipped.txt @@ -25,6 +25,7 @@ AgentCore.Domain.Audit.AuditEventKind.ReplyInterrupted = 2 -> AgentCore.Domain.A AgentCore.Domain.Audit.AuditEventKind.ToolFailed = 3 -> AgentCore.Domain.Audit.AuditEventKind AgentCore.Domain.Audit.AuditEventKind.TurnCompleted = 1 -> AgentCore.Domain.Audit.AuditEventKind AgentCore.Domain.Audit.AuditEventKind.TurnSuperseded = 7 -> AgentCore.Domain.Audit.AuditEventKind +AgentCore.Domain.Audit.AuditEventKind.TurnRefused = 8 -> AgentCore.Domain.Audit.AuditEventKind AgentCore.Domain.Audit.AuditEventKinds AgentCore.Domain.Audit.AuditEventVocabulary AgentCore.Domain.Audit.AuditHash @@ -114,7 +115,9 @@ AgentCore.Domain.TurnResult.Approvals.get -> System.Collections.Generic.IReadOnl AgentCore.Domain.TurnResult.Approvals.init -> void AgentCore.Domain.TurnResult.ConversationId.get -> string! AgentCore.Domain.TurnResult.ConversationId.init -> void -AgentCore.Domain.TurnResult.Deconstruct(out string! ConversationId, out int TurnIndex, out string! StageBefore, out string! StageAfter, out string! ReplyText, out bool IsTerminal, out string? ExtractionFailure, out string? Failure, out System.TimeSpan? InterruptedAfter, out System.DateTimeOffset EndedAt) -> void +AgentCore.Domain.TurnResult.Cut.get -> System.TimeSpan? +AgentCore.Domain.TurnResult.Cut.init -> void +AgentCore.Domain.TurnResult.Deconstruct(out string! ConversationId, out int TurnIndex, out string! StageBefore, out string! StageAfter, out string! ReplyText, out bool IsTerminal, out string? ExtractionFailure, out string? Failure, out System.TimeSpan? Cut, out System.DateTimeOffset EndedAt) -> void AgentCore.Domain.TurnResult.EndedAt.get -> System.DateTimeOffset AgentCore.Domain.TurnResult.EndedAt.init -> void AgentCore.Domain.TurnResult.Equals(AgentCore.Domain.TurnResult? other) -> bool @@ -122,8 +125,6 @@ AgentCore.Domain.TurnResult.ExtractionFailure.get -> string? AgentCore.Domain.TurnResult.ExtractionFailure.init -> void AgentCore.Domain.TurnResult.Failure.get -> string? AgentCore.Domain.TurnResult.Failure.init -> void -AgentCore.Domain.TurnResult.InterruptedAfter.get -> System.TimeSpan? -AgentCore.Domain.TurnResult.InterruptedAfter.init -> void AgentCore.Domain.TurnResult.IsTerminal.get -> bool AgentCore.Domain.TurnResult.IsTerminal.init -> void AgentCore.Domain.TurnResult.ReplyText.get -> string! @@ -134,7 +135,7 @@ AgentCore.Domain.TurnResult.StageBefore.get -> string! AgentCore.Domain.TurnResult.StageBefore.init -> void AgentCore.Domain.TurnResult.TurnIndex.get -> int AgentCore.Domain.TurnResult.TurnIndex.init -> void -AgentCore.Domain.TurnResult.TurnResult(string! ConversationId, int TurnIndex, string! StageBefore, string! StageAfter, string! ReplyText, bool IsTerminal, string? ExtractionFailure, string? Failure = null, System.TimeSpan? InterruptedAfter = null, System.DateTimeOffset EndedAt = default(System.DateTimeOffset)) -> void +AgentCore.Domain.TurnResult.TurnResult(string! ConversationId, int TurnIndex, string! StageBefore, string! StageAfter, string! ReplyText, bool IsTerminal, string? ExtractionFailure, string? Failure = null, System.TimeSpan? Cut = null, System.DateTimeOffset EndedAt = default(System.DateTimeOffset)) -> void const AgentCore.Domain.Audit.AuditHash.Length = 64 -> int const AgentCore.Domain.Audit.AuditPayloadKeys.DurationUntilInterruptMs = "durationUntilInterruptMs" -> string! const AgentCore.Domain.Audit.AuditPayloadKeys.EndReason = "endReason" -> string! @@ -149,6 +150,7 @@ const AgentCore.Domain.Audit.AuditPayloadKeys.ToolName = "toolName" -> string! const AgentCore.Domain.Audit.AuditPayloadKeys.UtteranceUntilInterruptSha256 = "utteranceUntilInterruptSha256" -> string! const AgentCore.Domain.Audit.AuditPayloadKeys.WithdrewFromTurnIndex = "withdrewFromTurnIndex" -> string! const AgentCore.Domain.Audit.AuditPayloadKeys.WithdrewThroughTurnIndex = "withdrewThroughTurnIndex" -> string! +const AgentCore.Domain.Audit.AuditPayloadKeys.RefusedReason = "refusedReason" -> string! override AgentCore.Domain.Audit.AuditEvent.Equals(object? obj) -> bool override AgentCore.Domain.Audit.AuditEvent.GetHashCode() -> int override AgentCore.Domain.Audit.AuditEvent.ToString() -> string! diff --git a/src/AgentCore.Domain/TurnResult.cs b/src/AgentCore.Domain/TurnResult.cs index 7d45fd98..394e6c79 100644 --- a/src/AgentCore.Domain/TurnResult.cs +++ b/src/AgentCore.Domain/TurnResult.cs @@ -37,7 +37,7 @@ namespace AgentCore.Domain /// /// The text the caller heard. It is what the agent spoke, the spoken fallback when /// is set, and the truncated reply when - /// is set. + /// is set. /// /// Whether the stage after the turn ends the conversation. /// @@ -47,9 +47,11 @@ namespace AgentCore.Domain /// The reason the turn spoke the fallback instead of a reply, or when the turn /// answered. It names the row of section 8.7 that the turn met. /// - /// - /// How long the caller heard the reply before speaking over it, or when the - /// caller did not interrupt. The relay reports it at 1 ms, so it is never estimated. + /// + /// when the reply was not cut. A value when it was: how long the caller heard + /// the reply, as the relay reports it at 1 ms. also means the play time is + /// unknown, as on every text abort. The audit record keeps the real "unknown": its + /// reply.interrupted event then carries no durationUntilInterruptMs. /// /// /// The moment the turn ended, read from the clock the turn loop was given. The audit chain of D23 @@ -68,7 +70,7 @@ public sealed record TurnResult( bool IsTerminal, string? ExtractionFailure, string? Failure = null, - TimeSpan? InterruptedAfter = null, + TimeSpan? Cut = null, DateTimeOffset EndedAt = default) { /// diff --git a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs index 60bec7e9..0b5fcbd6 100644 --- a/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostEndpointExtensions.cs @@ -1,4 +1,4 @@ -using AgentCore.AspNetCore.Conversation; +using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.Endpoints; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs index 5430dc5f..8dfce6b6 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStore.cs @@ -190,6 +190,32 @@ public async ValueTask SweepAsync( } } + /// + public async ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(conversationId); + ArgumentNullException.ThrowIfNull(holder); + + await using NpgsqlCommand command = _dataSource.CreateCommand(MarkBusySql); + _ = command.Parameters.Add(new NpgsqlParameter { Value = conversationId }); + _ = command.Parameters.Add(new NpgsqlParameter { Value = holder }); + _ = command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Interval, Value = lease }); + return await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) is true; + } + + /// + public async ValueTask ClearBusyAsync(string conversationId, string holder, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(conversationId); + ArgumentNullException.ThrowIfNull(holder); + + await using NpgsqlCommand command = _dataSource.CreateCommand(ClearBusySql); + _ = command.Parameters.Add(new NpgsqlParameter { Value = conversationId }); + _ = command.Parameters.Add(new NpgsqlParameter { Value = holder }); + _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + /// public async ValueTask AttachPrincipalAsync( string conversationId, string principalKey, string role, CancellationToken cancellationToken = default) @@ -251,6 +277,12 @@ public ValueTask RewriteAsync( return _words.RewriteAsync(conversationId, messageId, content, cancellationToken); } + /// + public ValueTask DeleteMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) + { + return _words.DeleteMessageAsync(conversationId, messageId, cancellationToken); + } + /// public ValueTask> ReadForSessionAsync( string conversationId, CancellationToken cancellationToken = default) @@ -286,43 +318,35 @@ public ValueTask EraseAsync(string conversationId, CancellationToken cancel } /// - public async ValueTask SaveContinuationAsync(string continuationId, string conversationId, JsonElement envelope, CancellationToken cancellationToken = default) + public async ValueTask SaveContinuationAsync(string responseId, string conversationId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(continuationId); + ArgumentNullException.ThrowIfNull(responseId); ArgumentNullException.ThrowIfNull(conversationId); await using NpgsqlCommand command = _dataSource.CreateCommand(SaveContinuationSql); - _ = command.Parameters.Add(new NpgsqlParameter { Value = continuationId }); + _ = command.Parameters.Add(new NpgsqlParameter { Value = responseId }); _ = command.Parameters.Add(new NpgsqlParameter { Value = conversationId }); - _ = command.Parameters.Add(new NpgsqlParameter - { - NpgsqlDbType = NpgsqlDbType.Jsonb, - Value = envelope.GetRawText(), - }); _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } /// - public async ValueTask GetContinuationAsync(string continuationId, CancellationToken cancellationToken = default) + public async ValueTask FindContinuationAsync(string responseId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(continuationId); + ArgumentNullException.ThrowIfNull(responseId); - await using NpgsqlCommand command = _dataSource.CreateCommand(GetContinuationSql); - _ = command.Parameters.Add(new NpgsqlParameter { Value = continuationId }); + await using NpgsqlCommand command = _dataSource.CreateCommand(FindContinuationSql); + _ = command.Parameters.Add(new NpgsqlParameter { Value = responseId }); - await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - return !await reader.ReadAsync(cancellationToken).ConfigureAwait(false) - ? null - : JsonDocument.Parse(reader.GetString(0)).RootElement.Clone(); + return await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; } /// - public async ValueTask DeleteContinuationAsync(string continuationId, CancellationToken cancellationToken = default) + public async ValueTask DeleteContinuationAsync(string responseId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(continuationId); + ArgumentNullException.ThrowIfNull(responseId); await using NpgsqlCommand command = _dataSource.CreateCommand(DeleteContinuationSql); - _ = command.Parameters.Add(new NpgsqlParameter { Value = continuationId }); + _ = command.Parameters.Add(new NpgsqlParameter { Value = responseId }); _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs index 675af5bd..b224acf3 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationStoreSql.cs @@ -30,6 +30,9 @@ SELECT max(updated_at) AS last_message_at FROM {Schema}.conversation_message x W ) m ON true """; + /// The turn a conversation takes next, read off its stored state. + private const string NextTurnIndex = "coalesce((state ->> 'nextTurnIndex')::int, 0)"; + internal const string CreateSql = $"INSERT INTO {Schema}.conversation (conversation_id) VALUES ($1) ON CONFLICT (conversation_id) DO NOTHING"; @@ -37,8 +40,21 @@ SELECT max(updated_at) AS last_message_at FROM {Schema}.conversation_message x W internal static readonly string GetSql = $"SELECT {Projection}, c.state, c.next_ordinal FROM {Schema}.conversation c {ActivityJoin} WHERE c.conversation_id = $1"; - internal const string StateSql = - $"UPDATE {Schema}.conversation SET state = $2, updated_at = now() WHERE conversation_id = $1"; + /// + /// Writes the state a turn's append carries, under the same turn check as : it runs in + /// the append's batch, after it, so a refused append refuses its state too. A state whose next turn is behind + /// the stored one's is never written, so no append puts back a state older than the one it replaces. + /// + internal static readonly string StateSql = + $""" + UPDATE {Schema}.conversation SET state = $2, updated_at = now() + WHERE conversation_id = $1 AND {TurnUnsaved(3)} + AND {NextTurnIndex} <= ($2::jsonb ->> 'nextTurnIndex')::int + """; + + /// Reads the turn a conversation takes next, to tell a refused append from a missing conversation. + internal const string NextTurnIndexSql = + $"SELECT {NextTurnIndex} FROM {Schema}.conversation WHERE conversation_id = $1"; internal const string RenameSql = $"UPDATE {Schema}.conversation SET title = $2, updated_at = now() WHERE conversation_id = $1"; @@ -86,15 +102,16 @@ ON CONFLICT (principal_key, conversation_id) DO NOTHING /// next_ordinal, so subtracting that same count back off it gives the first one this batch /// may use. A null element of $2 (turn_index) takes the conversation's own next turn index instead /// of naming one, which is what an append from outside any turn asks for. $6 is the - /// covers_up_to of each row: null for every row somebody said. + /// covers_up_to of each row: null for every row somebody said. $7 is the lowest turn index + /// the batch names, or null when it names none; see . /// - internal const string AppendSql = $""" + internal static readonly string AppendSql = $""" WITH mark AS ( UPDATE {Schema}.conversation SET next_ordinal = next_ordinal + cardinality($5::text[]), updated_at = now() - WHERE conversation_id = $1 + WHERE conversation_id = $1 AND {TurnUnsaved(7)} RETURNING next_ordinal - cardinality($5::text[]) AS first, - coalesce((state ->> 'nextTurnIndex')::int, 0) AS next_turn_index + {NextTurnIndex} AS next_turn_index ) INSERT INTO {Schema}.conversation_message (conversation_id, ordinal, turn_index, role, content, message_id, covers_up_to) SELECT $1, mark.first + d.position - 1, coalesce(d.turn_index, mark.next_turn_index), d.role, d.content, d.message_id, d.covers_up_to @@ -170,20 +187,25 @@ SELECT ordinal FROM {Schema}.conversation_message WHERE conversation_id = $1 AND message_id = $2 """; + internal const string DeleteMessageSql = $"DELETE FROM {Schema}.conversation_message WHERE conversation_id = $1 AND message_id = $2"; + internal const string EraseSql = $"DELETE FROM {Schema}.conversation_message WHERE conversation_id = $1"; - /// Files one session envelope under one continuation id, replacing any envelope already there. + /// + /// Records that one response id continues one conversation. A response id is written once, in the + /// turn's own commit, and never rewritten, so a row already on file is left as it is. + /// internal const string SaveContinuationSql = $""" - INSERT INTO {Schema}.response_continuation (store_id, conversation_id, envelope) VALUES ($1, $2, $3) - ON CONFLICT (store_id) DO UPDATE SET envelope = EXCLUDED.envelope, updated_at = now() + INSERT INTO {Schema}.response_continuation (store_id, conversation_id) VALUES ($1, $2) + ON CONFLICT (store_id) DO NOTHING """; - /// Reads the envelope one continuation id names. - internal const string GetContinuationSql = - $"SELECT envelope FROM {Schema}.response_continuation WHERE store_id = $1"; + /// Finds the conversation one response id continues. + internal const string FindContinuationSql = + $"SELECT conversation_id FROM {Schema}.response_continuation WHERE store_id = $1"; - /// Withdraws whatever one continuation id names, if anything. + /// Withdraws one response id, if it names a row. internal const string DeleteContinuationSql = $"DELETE FROM {Schema}.response_continuation WHERE store_id = $1"; @@ -191,9 +213,8 @@ ON CONFLICT (store_id) DO UPDATE SET envelope = EXCLUDED.envelope, updated_at = /// Reads what store 1 holds for each spoken turn of one conversation, beside what store 3 proves. /// internal const string VerifySql = $$""" - WITH spoken AS ( - SELECT DISTINCT ON (conversation_id, turn_index) - conversation_id, turn_index, + WITH rows AS ( + SELECT conversation_id, turn_index, ordinal, (SELECT coalesce(string_agg(part ->> 'text', '' ORDER BY position), '') FROM jsonb_array_elements(content -> 'contents') WITH ORDINALITY AS element(part, position) @@ -203,32 +224,77 @@ WITH ORDINALITY AS element(part, position) AND role = 'assistant' AND covers_up_to IS NULL AND content -> 'contents' @> '[{"$type": "text"}]' - ORDER BY conversation_id, turn_index, ordinal DESC + ), + spoken AS ( + SELECT conversation_id, turn_index, string_agg(words, '' ORDER BY ordinal) AS words + FROM rows + GROUP BY conversation_id, turn_index ), completed AS ( SELECT DISTINCT ON (conversation_id, turn_index) conversation_id, turn_index, payload FROM {{Schema}}.audit_event WHERE conversation_id = $1 AND kind = 'turn.completed' ORDER BY conversation_id, turn_index, sequence DESC + ), + interrupted AS ( + SELECT DISTINCT ON (conversation_id, turn_index) conversation_id, turn_index, payload + FROM {{Schema}}.audit_event + WHERE conversation_id = $1 AND kind = 'reply.interrupted' + ORDER BY conversation_id, turn_index, sequence DESC ) - SELECT m.turn_index, m.words, a.payload ->> 'replyTextSha256' - FROM spoken m JOIN completed a USING (conversation_id, turn_index) + SELECT m.turn_index, m.words, + coalesce(i.payload ->> 'utteranceUntilInterruptSha256', a.payload ->> 'replyTextSha256') + FROM spoken m + JOIN completed a USING (conversation_id, turn_index) + LEFT JOIN interrupted i USING (conversation_id, turn_index) ORDER BY m.turn_index """; /// - /// Deletes one batch of continuation rows that have gone untouched past the retention window. - /// Conversations are never swept: a chat id and every response id it ever advanced age out - /// on their own, independently of whether the conversation itself is still open. + /// Puts or extends a holder's busy mark, unless another holder's mark is still live. The conflict arm's + /// WHERE leaves a live mark of another holder as it is and returns no row. Lapse times are on the + /// database's clock, so hosts whose clocks disagree still agree on when a mark lapsed. + /// + internal const string MarkBusySql = + $""" + INSERT INTO {Schema}.conversation_busy AS b (conversation_id, holder, busy_until) + VALUES ($1, $2, now() + $3) + ON CONFLICT (conversation_id) DO UPDATE + SET holder = excluded.holder, busy_until = excluded.busy_until + WHERE b.holder = excluded.holder OR b.busy_until <= now() + RETURNING true + """; + + internal const string ClearBusySql = + $"DELETE FROM {Schema}.conversation_busy WHERE conversation_id = $1 AND holder = $2"; + + /// + /// Deletes one batch of response ids that have gone untouched past the retention window. A + /// response id is written once and never rewritten, so this ages a row out from when it was + /// created. Conversations are never swept: a chat id and every response id it ever advanced age + /// out on their own, independently of whether the conversation itself is still open. /// internal static readonly string SweepContinuationsSql = $""" DELETE FROM {Schema}.response_continuation WHERE store_id IN ( SELECT store_id FROM {Schema}.response_continuation - WHERE updated_at < now() - $1 + WHERE created_at < now() - $1 LIMIT $2 ) """; + + /// + /// Holds when a batch names no turn, or only turns the conversation has not saved. It is a condition on the + /// conversation row the statement updates, so under READ COMMITTED PostgreSQL re-checks it against the + /// newest row version after waiting out another writer's lock. A NOT EXISTS over the message rows would + /// read the snapshot taken before that wait and miss the other writer's turn. The parameter is the batch's + /// lowest named turn index. + /// + /// The position of that parameter in the statement. + private static string TurnUnsaved(int parameter) + { + return $"(${parameter}::int IS NULL OR {NextTurnIndex} <= ${parameter})"; + } } } diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs index 698633e9..d5043b5f 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationVerification.cs @@ -37,9 +37,18 @@ public static async Task> ReadSpokenTurnsAsy /// One spoken turn, as store 1 holds it and as store 3 proves it. /// The turn, which is the join between the two stores. - /// The words store 1 holds. A barge-in cut them down to what the caller heard. + /// + /// Every step's text store 1 holds for the turn, concatenated in row order. A barge-in cuts these rows + /// down to what the caller heard, so for a cut turn this already holds the shown words, not the words + /// the model produced. + /// /// - /// The digest the chain holds for the turn, or when its event carried none. + /// The digest should hash to: turn.completed's + /// for a turn nothing cut, or the + /// amending reply.interrupted's + /// for a turn one did — + /// the hash of the whole reply the model produced would not match rows that only hold what was shown. + /// Never : every saved turn has a turn.completed event. /// internal sealed record TranscriptTurnDigest(int TurnIndex, string Spoken, string? ReplyTextSha256); } diff --git a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs index 5919aa64..e26ef3e1 100644 --- a/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs +++ b/src/AgentCore.Infrastructure/Conversation/Postgres/PostgresConversationWords.cs @@ -37,6 +37,8 @@ public async ValueTask> AppendAsync( return []; } + int? lowestTurn = messages.Min(message => message.TurnIndex); + await using NpgsqlConnection connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); await using NpgsqlBatch batch = new(connection); @@ -63,6 +65,7 @@ public async ValueTask> AppendAsync( { TypedValue = [.. messages.Select(message => message.CoversUpTo)], }); + _ = appendCommand.Parameters.Add(Turn(lowestTurn)); batch.BatchCommands.Add(appendCommand); if (state is not null) @@ -74,6 +77,7 @@ public async ValueTask> AppendAsync( Value = JsonSerializer.Serialize(state, ConversationStateJson.Options), NpgsqlDbType = NpgsqlDbType.Jsonb, }); + _ = stateCommand.Parameters.Add(Turn(lowestTurn)); batch.BatchCommands.Add(stateCommand); } @@ -91,15 +95,45 @@ public async ValueTask> AppendAsync( } } - return written.Count == 0 - ? throw new InvalidOperationException($"Store 0 holds no conversation '{conversationId}' to append words to.") - : [.. messages.Select(draft => + if (written.Count == 0) + { + throw await RefusalAsync(connection, conversationId, lowestTurn, cancellationToken).ConfigureAwait(false); + } + + return [.. messages.Select(draft => { (int ordinal, int turnIndex) = written[draft.MessageId]; return new ConversationMessage(conversationId, ordinal, turnIndex, draft.Content, draft.MessageId) { CoversUpTo = draft.CoversUpTo }; })]; } + private static NpgsqlParameter Turn(int? lowestTurn) + { + return new NpgsqlParameter { TypedValue = lowestTurn, NpgsqlDbType = NpgsqlDbType.Integer }; + } + + /// + /// Names why an append wrote nothing. The read runs after the append's own transaction, so it only picks + /// the message: the refusal itself was decided atomically by the append. + /// + private static async ValueTask RefusalAsync( + NpgsqlConnection connection, string conversationId, int? lowestTurn, CancellationToken cancellationToken) + { + if (lowestTurn is { } named) + { + await using NpgsqlCommand command = new(NextTurnIndexSql, connection); + _ = command.Parameters.Add(new NpgsqlParameter { Value = conversationId }); + + if (await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) is int next) + { + return new ConversationTurnConflictException( + $"Conversation '{conversationId}' already saved turn {named} and takes turn {next} next, so nothing was written."); + } + } + + return new InvalidOperationException($"Store 0 holds no conversation '{conversationId}' to append words to."); + } + /// public async ValueTask RewriteAsync( string conversationId, string messageId, ChatMessage content, CancellationToken cancellationToken = default) @@ -116,6 +150,20 @@ public async ValueTask RewriteAsync( _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + /// + public async ValueTask DeleteMessageAsync( + string conversationId, string messageId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(conversationId); + ArgumentException.ThrowIfNullOrEmpty(messageId); + + await using NpgsqlCommand command = _dataSource.CreateCommand(DeleteMessageSql); + _ = command.Parameters.Add(new NpgsqlParameter { Value = conversationId }); + _ = command.Parameters.Add(new NpgsqlParameter { Value = messageId }); + + _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + private static async ValueTask> ReadRowsAsync( string conversationId, NpgsqlCommand command, CancellationToken cancellationToken) { diff --git a/src/AgentCore.Infrastructure/Database/Postgres/Migrations/002_conversation_busy.sql b/src/AgentCore.Infrastructure/Database/Postgres/Migrations/002_conversation_busy.sql new file mode 100644 index 00000000..7d438c39 --- /dev/null +++ b/src/AgentCore.Infrastructure/Database/Postgres/Migrations/002_conversation_busy.sql @@ -0,0 +1,14 @@ +-- The busy mark of a conversation while one of its turns runs: a lease that lapses at busy_until. +-- No foreign key to conversation: the first turn of a conversation marks it before its row exists. +-- UNLOGGED: a mark is written twice per turn, once before the model call, so its commit must not wait for a WAL +-- flush (measured 0.3-1 ms unlogged against 60-630 ms logged on a loaded disk). A crash of the server empties the +-- table, which frees every conversation exactly as lapsed leases would; the store's turn check stays the backstop. +CREATE UNLOGGED TABLE agentcore.conversation_busy ( + conversation_id text PRIMARY KEY, + holder text NOT NULL, + busy_until timestamptz NOT NULL +); + +REVOKE ALL ON agentcore.conversation_busy FROM PUBLIC; + +GRANT SELECT, INSERT, UPDATE, DELETE ON agentcore.conversation_busy TO agentcore_writer; diff --git a/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql b/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql new file mode 100644 index 00000000..c4f41fa8 --- /dev/null +++ b/src/AgentCore.Infrastructure/Database/Postgres/Migrations/003_response_continuation.sql @@ -0,0 +1,18 @@ +-- A response id no longer stores a session snapshot: conversation.state is the one snapshot, written +-- with the words in the same commit. response_continuation shrinks to the one fact worth keeping, which +-- conversation a response id continues, and when that row was written for the retention sweep. +-- +-- The rows whose store_id names a conversation were the old same-id pointer at store 0's own state; the +-- conversation table already answers for those ids, so the pointer rows go. +DELETE FROM agentcore.response_continuation rc + WHERE EXISTS ( + SELECT 1 FROM agentcore.conversation c WHERE c.conversation_id = rc.store_id + ); + +ALTER TABLE agentcore.response_continuation + DROP COLUMN envelope, + DROP COLUMN updated_at; + +-- Replaces response_continuation_retention_idx, dropped along with updated_at above: a response id is +-- written once and never rewritten, so the sweep ages a row out from when it was created. +CREATE INDEX response_continuation_retention_idx ON agentcore.response_continuation (created_at); diff --git a/src/AgentCore.Infrastructure/Database/Postgres/PostgresSchema.cs b/src/AgentCore.Infrastructure/Database/Postgres/PostgresSchema.cs index e71f6449..b4beb7a9 100644 --- a/src/AgentCore.Infrastructure/Database/Postgres/PostgresSchema.cs +++ b/src/AgentCore.Infrastructure/Database/Postgres/PostgresSchema.cs @@ -53,8 +53,14 @@ private static string Read(string version) /// The versions this conversation applied, oldest first. Empty when the schema was current. /// /// The connecting role also needs CREATEROLE the first time, because migration 001 creates - /// agentcore_writer. The running system connects as a member of that role, not as this one. + /// agentcore_writer. The running system connects as a member of that role, not as this one, so a + /// release that adds a migration is applied by the owning role before the running system starts on it. /// + /// + /// A migration is pending and this connection's role may not apply it, as a member of + /// agentcore_writer may not. Nothing was applied, and nothing is skipped: the message names the + /// pending migrations and the role that must apply them. The inner exception is the database's refusal. + /// public static async Task> ApplyAsync( NpgsqlDataSource dataSource, CancellationToken cancellationToken = default) @@ -72,6 +78,47 @@ public static async Task> ApplyAsync( _ = await serialise.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + // Until the ledger is read, every migration is pending as far as this connection knows. + IReadOnlyList pending = Versions; + try + { + pending = await PendingAsync(connection, transaction, cancellationToken).ConfigureAwait(false); + + foreach (string version in pending) + { + await ExecuteAsync(connection, transaction, Read(version), cancellationToken).ConfigureAwait(false); + + await using NpgsqlCommand record = new( + $"INSERT INTO {LedgerName} (version) VALUES ($1)", connection, transaction); + _ = record.Parameters.AddWithValue(version); + _ = await record.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (PostgresException refusal) when (refusal.SqlState == PostgresErrorCodes.InsufficientPrivilege) + { + string applier = pending.Contains(Versions[0]) + ? $"a role that may create the '{SchemaName}' schema in this database, and may create roles if " + + "agentcore_writer does not exist yet" + : $"the role that applied {Versions[0]}, the owner of the '{SchemaName}' schema"; + + throw new InvalidOperationException( + $"The database is missing the AgentCore schema migration(s) {string.Join(", ", pending)}, and the role " + + $"'{connection.UserName}' may not apply them, so this host will not start on it. Apply them as " + + $"{applier}: call PostgresSchema.ApplyAsync with a data source that connects as that role, or start " + + "one host with that role's connection string. Then start this host again.", + refusal); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return pending; + } + + /// Creates the schema and the ledger when they are missing, and reads which migrations are not applied yet. + private static async Task> PendingAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + CancellationToken cancellationToken) + { bool schemaExists = await ScalarAsync( connection, transaction, $"SELECT to_regnamespace('{SchemaName}') IS NOT NULL", cancellationToken).ConfigureAwait(false); @@ -93,27 +140,8 @@ public static async Task> ApplyAsync( } HashSet applied = await ReadAppliedAsync(connection, transaction, cancellationToken).ConfigureAwait(false); - List ran = []; - - foreach (string version in Versions) - { - if (applied.Contains(version)) - { - continue; - } - - await ExecuteAsync(connection, transaction, Read(version), cancellationToken).ConfigureAwait(false); - - await using NpgsqlCommand record = new( - $"INSERT INTO {LedgerName} (version) VALUES ($1)", connection, transaction); - _ = record.Parameters.AddWithValue(version); - _ = await record.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - ran.Add(version); - } - - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); - return ran; + return [.. Versions.Where(version => !applied.Contains(version))]; } private static async Task> ReadAppliedAsync( diff --git a/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs b/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs index 1ea973a8..a3d1b699 100644 --- a/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/AuditConversationObserverTests.cs @@ -16,7 +16,7 @@ public sealed class AuditConversationObserverTests private static readonly DateTimeOffset Moment = new(2026, 8, 15, 9, 30, 0, TimeSpan.Zero); - /// The six kinds that are counted or logged or both, and stored nowhere. + /// The kinds that are counted or logged or both, and stored nowhere. public static TheoryData DiagnosticKinds => [ ConversationEventKind.ModerationUnavailable, @@ -26,6 +26,7 @@ public sealed class AuditConversationObserverTests ConversationEventKind.TranscriptWriteFailed, ConversationEventKind.StateRestorePartial, ConversationEventKind.TranscriptResyncFailed, + ConversationEventKind.RunFaulted, ]; /// Every kind, once, across the two tables above. @@ -41,7 +42,7 @@ .. StoredKinds.Select(row => (ConversationEventKind)((ITheoryDataRow)row).GetDat Assert.Equal([.. Enum.GetValues().Order()], [.. named.Order()]); } - /// The seven kinds the store keeps, beside the token each one writes. + /// The eight kinds the store keeps, beside the token each one writes. public static TheoryData StoredKinds => new() { @@ -52,6 +53,7 @@ .. StoredKinds.Select(row => (ConversationEventKind)((ITheoryDataRow)row).GetDat { ConversationEventKind.ReplyInterrupted, AuditEventKind.ReplyInterrupted }, { ConversationEventKind.ConversationEnded, AuditEventKind.ConversationEnded }, { ConversationEventKind.TurnSuperseded, AuditEventKind.TurnSuperseded }, + { ConversationEventKind.TurnRefused, AuditEventKind.TurnRefused }, }; [Theory] @@ -262,9 +264,11 @@ or ConversationEventKind.EmptyReply or ConversationEventKind.ExtractionFailed or ConversationEventKind.TurnCompleted or ConversationEventKind.TurnSuperseded + or ConversationEventKind.TurnRefused or ConversationEventKind.TranscriptWriteFailed or ConversationEventKind.StateRestorePartial - or ConversationEventKind.TranscriptResyncFailed => new Dictionary(StringComparer.Ordinal), + or ConversationEventKind.TranscriptResyncFailed + or ConversationEventKind.RunFaulted => new Dictionary(StringComparer.Ordinal), _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "The conversation event vocabulary is closed, and this value is not in it."), }; } diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs new file mode 100644 index 00000000..22f9f72d --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditConversationEndedTests.cs @@ -0,0 +1,133 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain.Audit; +using Xunit; +using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; + +namespace AgentCore.Application.Tests.Audit +{ + /// The four ways section 4 lets a conversation end, and what each one writes to the chain. + public sealed class ConversationSessionAuditConversationEndedTests + { + [Fact] + public async Task ATerminalStage_ClosesTheChainWithOneConversationEndedEvent() + { + using SequencedChatClient reply = new("goodbye."); + using SequencedChatClient fill = new(SaidGoodbye); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + _ = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + + Assert.True(session.IsComplete); + AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + Assert.Null(ended.TurnIndex); + + // The reason is one token of the closed set, so a report can count it. The terminal stage is + // detail beside it, and it is not the reason. + Assert.Equal("agent.completed", ended.Payload[AuditPayloadKeys.EndReason]); + Assert.Equal("close", ended.Payload[AuditPayloadKeys.StageAfter]); + + // A hang-up frame that arrives after the machine already closed the conversation writes nothing. + Assert.False(session.EndConversation(ConversationEndReason.CallerHungUp)); + } + + // No row of a conversation lands after its conversation.ended. The turn that reaches the terminal stage + // writes its rows first, so the end is dated once they landed. + [Fact] + public async Task ATerminalStage_DatesTheEndAfterTheTurnsRowsLanded() + { + using SequencedChatClient reply = new("goodbye."); + using SequencedChatClient fill = new(SaidGoodbye); + InMemoryAuditSink sink = new(); + TestTimeProvider clock = new(); + SlowAppends store = new(new InMemoryConversationStore(), clock, TimeSpan.FromSeconds(3)); + ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock, auditSink: sink, store: store) + .Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + + Assert.True(turn.IsTerminal); + AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + Assert.NotNull(store.LandedAt); + Assert.True( + ended.OccurredAt >= store.LandedAt, + $"conversation.ended is dated {ended.OccurredAt:O}, before the turn's rows landed at {store.LandedAt:O}."); + } + + [Fact] + public void AHostThatEndsTheConversation_ClosesTheChainOnce() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + Assert.True(session.EndConversation(ConversationEndReason.CallerHungUp)); + Assert.False(session.EndConversation(ConversationEndReason.CallerHungUp)); + + AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + Assert.Equal("caller.hangup", ended.Payload[AuditPayloadKeys.EndReason]); + + // The machine never ran, so no stage closed the conversation and no stage rides on the event. + Assert.False(ended.Payload.ContainsKey(AuditPayloadKeys.StageAfter)); + Assert.True(session.IsComplete); + } + + /// Section 11, item 5: the conversation goes to a human through the conference pattern. + [Fact] + public void ATransferToAHuman_ClosesTheChainWithItsOwnReason() + { + using SequencedChatClient reply = new("one moment please."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + // The adapter joins the conversation to a conference and never sends the transfer command (T27). + Assert.True(session.EndConversation(ConversationEndReason.TransferredToHuman)); + + AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + Assert.Equal("agent.transferred", ended.Payload[AuditPayloadKeys.EndReason]); + } + + [Fact] + public void AReasonOutsideTheClosedSet_EndsNoConversation() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + _ = Assert.Throws(() => session.EndConversation((ConversationEndReason)99)); + + // Nothing moved. The conversation still runs, and the chain still holds only its first event. + Assert.False(session.IsComplete); + Assert.DoesNotContain(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); + } + + /// A store whose appends take time on the test's clock, and that notes when the last one landed. + private sealed class SlowAppends(IConversationStore inner, TestTimeProvider clock, TimeSpan takes) : DelegatingConversationStore(inner) + { + public DateTimeOffset? LandedAt { get; private set; } + + public override async ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + IReadOnlyList rows = await Inner.AppendAsync(conversationId, messages, state, cancellationToken); + clock.Advance(takes); + LandedAt = clock.GetUtcNow(); + return rows; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs new file mode 100644 index 00000000..bde84f6c --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditEventsTests.cs @@ -0,0 +1,142 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; + +namespace AgentCore.Application.Tests.Audit +{ + /// + /// The turn loop writes the audit chain of D23, and the sink never sits on the turn. + /// + public sealed class ConversationSessionAuditEventsTests + { + // The events, and the identity a later amendment names. + [Fact] + public void ANewSession_WritesTheConversationStartedEvent() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + AuditEvent started = Assert.Single(sink.EventsOf("conversation-1")); + Assert.Equal(AuditEventKind.ConversationStarted, started.Kind); + Assert.NotEqual(Guid.Empty, started.EventId); + Assert.Null(started.TurnIndex); + Assert.Null(started.AmendsEventId); + Assert.Equal(session.ConversationId, started.ConversationId); + } + + [Fact] + public async Task ASecondSessionOfOneConversation_CollidesWithNothing() + { + using SequencedChatClient reply = new("hello there.", "still here."); + using SequencedChatClient fill = new(StayingNull, StayingNull); + InMemoryAuditSink sink = new(); + + // One factory, so both sessions share one compiled agent and one store — which is what a host + // holds, and what makes the second session a resume rather than a different conversation. + ConversationSessionFactory factory = Build(PolicyYaml, reply, fill, auditSink: sink); + + ConversationSession first = factory.Create("conversation-1"); + _ = await first.RunTurnAsync("hello", TestContext.Current.CancellationToken); + await first.FlushTranscriptAsync(); + + ConversationSession second = factory.Create("conversation-1"); + _ = await second.RunTurnAsync("still there?", TestContext.Current.CancellationToken); + await second.FlushTranscriptAsync(); + + IReadOnlyList written = sink.EventsOf("conversation-1"); + + // The shape is the assertion, and both halves of it are the point. + // + // ARRIVAL: a second session that restarted its counter at zero would have every event it raised + // refused by store 3, and a resumed conversation would lose its whole audit trail. The proof is + // that the second session's two events are HERE — distinctness proves nothing, + // because InMemoryAuditSink enforces no key and Guid.CreateVersion7 is unique by + // construction, so that assertion passed just as well over the two events that never arrived. + // + // A SECOND conversation.started: a session opening onto a conversation that already has words raises one, and + // it is kept rather than suppressed. See AuditEventKind.ConversationStarted for why. This is where + // that decision is visible, so a build that quietly stopped raising it fails here. + Assert.Equal( + [ + AuditEventKind.ConversationStarted, + AuditEventKind.TurnCompleted, + AuditEventKind.ConversationStarted, + AuditEventKind.TurnCompleted, + ], + written.Select(item => item.Kind).ToArray()); + + Guid[] ids = [.. written.Select(item => item.EventId)]; + Assert.Equal(ids.Length, ids.Distinct().Count()); + Assert.DoesNotContain(Guid.Empty, ids); + } + + [Fact] + public async Task AFinishedTurn_WritesOneTurnCompletedEventWithBothStages() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + TestTimeProvider clock = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock, auditSink: sink).Create("conversation-1"); + + clock.Advance(TimeSpan.FromSeconds(12)); + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + AuditEvent completed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.TurnCompleted); + Assert.Same(completed, sink.EventsOf("conversation-1")[1]); + Assert.Equal(0, completed.TurnIndex); + Assert.Equal(AuditHash.OfText("hello there.").Value, completed.Payload[AuditPayloadKeys.ReplyTextSha256]); + Assert.Equal("greeting", completed.Payload[AuditPayloadKeys.StageBefore]); + Assert.Equal("greeting", completed.Payload[AuditPayloadKeys.StageAfter]); + + // The moment comes from the injected clock and not from the sink. A background writer would + // stamp it one enqueue late. + Assert.Equal(clock.GetUtcNow(), completed.OccurredAt); + Assert.Equal(completed.OccurredAt, turn.EndedAt); + } + + [Fact] + public async Task ABargeIn_WritesASecondEventThatAmendsTheTurnEvent() + { + using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + // The caller heard the first fragment, then spoke over the rest. + await using (IAsyncEnumerator updates = session + .RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken) + .GetAsyncEnumerator(TestContext.Current.CancellationToken)) + { + Assert.True(await updates.MoveNextAsync()); + Assert.True(session.Cut(0, new TurnCut("hel", TimeSpan.FromMilliseconds(120)))); + Assert.False(await updates.MoveNextAsync()); + } + + IReadOnlyList events = sink.EventsOf("conversation-1"); + AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); + AuditEvent cut = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); + + // T23: the chain is append-only, so an amendment is a second event that references the first. + IReadOnlyList chain = sink.EventsOf("conversation-1"); + Assert.Equal(completed.EventId, cut.AmendsEventId); + Assert.True(chain.ToList().IndexOf(cut) > chain.ToList().IndexOf(completed)); + Assert.Equal(completed.TurnIndex, cut.TurnIndex); + + // Item 6a: the event records the text the caller ACTUALLY HEARD. Nothing here is estimated, + // because the relay reported both values on its interrupt frame. + Assert.Equal(AuditHash.OfText("hel").Value, cut.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + Assert.Equal("120", cut.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs new file mode 100644 index 00000000..bf3667d9 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditSinkTests.cs @@ -0,0 +1,128 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Diagnostics; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.Logging; +using Xunit; +using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; + +namespace AgentCore.Application.Tests.Audit +{ + /// + /// Item 6 and D23, from the sink's side: the chain the sink holds verifies, the write never sits + /// on the turn, and a sink that misbehaves — or is absent — never harms the caller. + /// + public sealed class ConversationSessionAuditSinkTests + { + // Item 6: the chain the events form verifies. + [Fact] + public async Task TheEventsOfOneConversation_FormAChainThatVerifies() + { + using SequencedChatClient reply = new("hello there.", "goodbye."); + using SequencedChatClient fill = new(StayingNull, SaidGoodbye); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + CancellationToken token = TestContext.Current.CancellationToken; + _ = await session.RunTurnAsync("hi", token); + _ = await session.RunTurnAsync("goodbye", token); + + IReadOnlyList events = sink.EventsOf("conversation-1"); + + // Four facts, four identities, none of them repeated. What orders them is + // audit_event.sequence, which the store assigns and which this test never sees. + Assert.Equal(4, events.Count); + Assert.Equal(events.Count, events.Select(item => item.EventId).Distinct().Count()); + + // This is chain_check of section 11, item 6, run over what the turn loop produced. + Assert.All(events, AuditEventVocabulary.Validate); + } + + // D23: the write never sits on the turn. + [Fact] + public async Task TheTurn_FinishesWhileTheAppendIsStillOpen() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + BlockingAuditSink sink = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); + + // Section 7: a durable insert costs 13 ms at p50 and 32 ms at p99, against 91 nanoseconds to + // enqueue. The turn therefore finishes with no append complete at all. + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal("hello there.", turn.ReplyText); + + // The whole turn ran and returned while conversation.started is still inside the sink. The + // turn.completed event is queued behind it rather than racing past it, because the events of + // one conversation reach an observer in the order the conversation raised them, so exactly one has arrived. + _ = Assert.Single(sink.Events); + + sink.Release(); + + // Off the turn, and only now. The queued event lands on the dispatcher's own task, which is + // where a slow sink is paid for. + DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + while (sink.Events.Count < 2 && DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(10, TestContext.Current.CancellationToken); + } + + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], + sink.Events.Select(item => item.Kind).ToArray()); + } + + [Fact] + public async Task ASinkThatRefusesAnEvent_IsLoggedAndTheTurnGoesOn() + { + RecordingLogger logger = new(); + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: new ThrowingAuditSink(), logger: logger) + .Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // Audit is a record of the conversation and never a part of it. + Assert.Equal("hello there.", turn.ReplyText); + Assert.NotEmpty(logger.Of(5)); + Assert.All(logger.Of(5), line => Assert.Equal(LogLevel.Error, line.Level)); + } + + [Fact] + public async Task ASessionWithNoSink_RunsATurnAndThrowsNothing() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal("hello there.", turn.ReplyText); + } + + // The in-memory sink. + [Fact] + public async Task TheInMemorySink_KeepsTheConversationsApart() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + InMemoryAuditSink sink = new(); + ConversationSessionFactory factory = Build(PolicyYaml, reply, fill, auditSink: sink); + + ConversationSession first = factory.Create("conversation-1"); + ConversationSession second = factory.Create("conversation-2"); + _ = await first.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // One sink serves every conversation, because a session names itself on every event. + Assert.Equal(2, sink.EventsOf("conversation-1").Count); + _ = Assert.Single(sink.EventsOf("conversation-2")); + Assert.Equal(3, sink.Events.Count); + Assert.Equal("conversation-2", second.ConversationId); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs new file mode 100644 index 00000000..fe382594 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTestSupport.cs @@ -0,0 +1,102 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Audit +{ + /// What every part of the audit test suite shares: the two fixture documents, the two + /// extractor replies, and the plumbing to compile one and open a session against it. + internal static class ConversationSessionAuditTestSupport + { + internal const string PolicyYaml = + """ + apiVersion: agentcore/v1 + state: + callerSaidGoodbye: { type: boolean, default: false, writer: extractor } + guards: + saidGoodbye: { var: callerSaidGoodbye } + extractor: + model: { ref: fill } + when: after_reply + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + - { id: closer, instructions: "close the conversation" } + entries: + main: + policy: + initial: greeting + stages: + - { id: greeting, agent: greeter, to: [ { stage: close, when: saidGoodbye } ] } + - { id: close, agent: closer, terminal: true } + """; + + internal const string ToolYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } + agents: + defaults: + model: { ref: reply } + items: + - { id: only, instructions: "I answer everything", tools: [ lookup_order ] } + entries: + main: + agent: only + """; + + internal const string StayingNull = /*lang=json,strict*/ """{ "callerSaidGoodbye": null }"""; + internal const string SaidGoodbye = /*lang=json,strict*/ """{ "callerSaidGoodbye": true }"""; + + internal static ConversationSessionFactory Build( + string yaml, + IChatClient reply, + IChatClient? fill, + Func? tools = null, + TimeProvider? timeProvider = null, + IAuditSinkPort? auditSink = null, + ILogger? logger = null, + IConversationStore? store = null) + { + // ConversationObservers.Standard takes a required sink, because the composition root resolves + // providers.audit for every host and falls back to the in-process memory kind. An optional + // parameter has to be a compile-time constant, so the default is spelled here instead — a fact + // that does not care where its events land gets a fresh in-memory sink. + IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); + + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + RoutingChatClientFactory chatClients = new(reply); + if (fill is not null) + { + _ = chatClients.Route("fill", fill); + } + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) + { + Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), + ConversationStore = store, + })["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, chatClients), + timeProvider, + logger, + ConversationObservers.Standard(sink, logger)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTests.cs deleted file mode 100644 index d257cc72..00000000 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditTests.cs +++ /dev/null @@ -1,628 +0,0 @@ -using AgentCore.TestSupport; -using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Application.Tests.Diagnostics; -using AgentCore.Application.Tests.Fakes; -using AgentCore.Application.Tests.Runtime; -using AgentCore.Domain.Audit; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Xunit; -using AgentCore.Domain; - -namespace AgentCore.Application.Tests.Audit -{ - /// - /// The turn loop writes the audit chain of D23, and the sink never sits on the turn. - /// - /// - /// The session is the only place that knows the turn index, both stages, and the identity a later - /// amendment must name, so it is the only place that produces an event. - /// - public sealed class ConversationSessionAuditTests - { - private const string PolicyYaml = - """ - apiVersion: agentcore/v1 - state: - callerSaidGoodbye: { type: boolean, default: false, writer: extractor } - guards: - saidGoodbye: { var: callerSaidGoodbye } - extractor: - model: { ref: fill } - when: after_reply - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "greet the caller" } - - { id: closer, instructions: "close the conversation" } - entries: - main: - policy: - initial: greeting - stages: - - { id: greeting, agent: greeter, to: [ { stage: close, when: saidGoodbye } ] } - - { id: close, agent: closer, terminal: true } - """; - - private const string ToolYaml = - """ - apiVersion: agentcore/v1 - tools: - - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } - agents: - defaults: - model: { ref: reply } - items: - - { id: only, instructions: "I answer everything", tools: [ lookup_order ] } - entries: - main: - agent: only - """; - - private const string StayingNull = /*lang=json,strict*/ """{ "callerSaidGoodbye": null }"""; - private const string SaidGoodbye = /*lang=json,strict*/ """{ "callerSaidGoodbye": true }"""; - - // ------------------------------------------------------------------------------------------- - // The events, and the identity a later amendment names. - // ------------------------------------------------------------------------------------------- - [Fact] - public void ANewSession_WritesTheConversationStartedEvent() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - AuditEvent started = Assert.Single(sink.EventsOf("conversation-1")); - Assert.Equal(AuditEventKind.ConversationStarted, started.Kind); - Assert.NotEqual(Guid.Empty, started.EventId); - Assert.Null(started.TurnIndex); - Assert.Null(started.AmendsEventId); - Assert.Equal(session.ConversationId, started.ConversationId); - } - - [Fact] - public async Task ASecondSessionOfOneConversation_CollidesWithNothing() - { - using SequencedChatClient reply = new("hello there.", "still here."); - using SequencedChatClient fill = new(StayingNull, StayingNull); - InMemoryAuditSink sink = new(); - - // One factory, so both sessions share one compiled agent and one store — which is what a host - // holds, and what makes the second session a resume rather than a different conversation. - ConversationSessionFactory factory = Build(PolicyYaml, reply, fill, auditSink: sink); - - ConversationSession first = factory.Create("conversation-1"); - _ = await first.RunTurnAsync("hello", TestContext.Current.CancellationToken); - await first.FlushTranscriptAsync(); - - ConversationSession second = factory.Create("conversation-1"); - _ = await second.RunTurnAsync("still there?", TestContext.Current.CancellationToken); - await second.FlushTranscriptAsync(); - - IReadOnlyList written = sink.EventsOf("conversation-1"); - - // The shape is the assertion, and both halves of it are the point. - // - // ARRIVAL: before this change the second session restarted its counter at zero, store 3 - // refused every event it raised, and a resumed conversation lost its whole audit trail. The proof of - // the fix is that the second session's two events are HERE — distinctness proves nothing, - // because InMemoryAuditSink enforces no key and Guid.CreateVersion7 is unique by - // construction, so that assertion passed just as well over the two events that never arrived. - // - // A SECOND conversation.started: a session opening onto a conversation that already has words raises one, and - // it is kept rather than suppressed. See AuditEventKind.ConversationStarted for why. This is where - // that decision is visible, so a build that quietly stopped raising it fails here. - Assert.Equal( - [ - AuditEventKind.ConversationStarted, - AuditEventKind.TurnCompleted, - AuditEventKind.ConversationStarted, - AuditEventKind.TurnCompleted, - ], - written.Select(item => item.Kind).ToArray()); - - Guid[] ids = [.. written.Select(item => item.EventId)]; - Assert.Equal(ids.Length, ids.Distinct().Count()); - Assert.DoesNotContain(Guid.Empty, ids); - } - - [Fact] - public async Task AFinishedTurn_WritesOneTurnCompletedEventWithBothStages() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - TestTimeProvider clock = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock, auditSink: sink).Create("conversation-1"); - - clock.Advance(TimeSpan.FromSeconds(12)); - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - AuditEvent completed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.TurnCompleted); - Assert.Same(completed, sink.EventsOf("conversation-1")[1]); - Assert.Equal(0, completed.TurnIndex); - Assert.Equal(AuditHash.OfText("hello there.").Value, completed.Payload[AuditPayloadKeys.ReplyTextSha256]); - Assert.Equal("greeting", completed.Payload[AuditPayloadKeys.StageBefore]); - Assert.Equal("greeting", completed.Payload[AuditPayloadKeys.StageAfter]); - - // The moment comes from the injected clock and not from the sink. A background writer would - // stamp it one enqueue late. - Assert.Equal(clock.GetUtcNow(), completed.OccurredAt); - Assert.Equal(completed.OccurredAt, turn.EndedAt); - } - - [Fact] - public async Task ABargeIn_WritesASecondEventThatAmendsTheTurnEvent() - { - using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - // The streaming shape, because only an audible turn is cut in flight: the caller heard the - // first fragment, then spoke over the rest. A turn that never streamed is never audible and - // its frame amends the turn before it instead — see AnInterruption_NeverCutsATurnThatDoesNotStream. - await using (IAsyncEnumerator updates = session - .RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken) - .GetAsyncEnumerator(TestContext.Current.CancellationToken)) - { - Assert.True(await updates.MoveNextAsync()); - Assert.True(session.Interrupt("hel", TimeSpan.FromMilliseconds(120))); - Assert.False(await updates.MoveNextAsync()); - } - - IReadOnlyList events = sink.EventsOf("conversation-1"); - AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); - AuditEvent cut = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); - - // T23: the chain is append-only, so an amendment is a second event that references the first. - IReadOnlyList chain = sink.EventsOf("conversation-1"); - Assert.Equal(completed.EventId, cut.AmendsEventId); - Assert.True(chain.ToList().IndexOf(cut) > chain.ToList().IndexOf(completed)); - Assert.Equal(completed.TurnIndex, cut.TurnIndex); - - // Item 6a: the event records the text the caller ACTUALLY HEARD. Nothing here is estimated, - // because the relay reported both values on its interrupt frame. - Assert.Equal(AuditHash.OfText("hel").Value, cut.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); - Assert.Equal("120", cut.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); - } - - [Fact] - public async Task TheFourthConsecutiveToolFailure_WritesAToolFailedEventBeforeTheTurnEvent() - { - using LoopingToolCallingChatClient reply = new(); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(ToolYaml, reply, null, new ThrowingToolBuilder().Create, auditSink: sink).Create("conversation-1"); - - _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - IReadOnlyList events = sink.EventsOf("conversation-1"); - AuditEvent[] failures = [.. events.Where(item => item.Kind == AuditEventKind.ToolFailed)]; - AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); - - // The kind is raised at two altitudes and both are here: one row for each conversation that failed, - // and one for the turn that ended because of them. Five in all, and every one of them before - // the turn event. - Assert.Equal(5, failures.Length); - List order = [.. sink.EventsOf("conversation-1")]; - Assert.All(failures, failure => Assert.True(order.IndexOf(failure) < order.IndexOf(completed))); - Assert.All(failures, failure => Assert.Equal(0, failure.TurnIndex)); - - // The turn-level row is the one section 8.7 row six has always written, and it is unchanged: - // no tool is named, because the fault reaches the session with no function name on it, and a - // missing fact is an absent key rather than the word "unknown". - AuditEvent turnLevel = Assert.Single( - failures, - failure => !failure.Payload.ContainsKey(AuditPayloadKeys.ToolCallId)); - Assert.False(turnLevel.Payload.ContainsKey(AuditPayloadKeys.ToolName)); - Assert.Contains( - ThrowingToolBuilder.Message, - turnLevel.Payload[AuditPayloadKeys.ToolError], - StringComparison.Ordinal); - - // The four call-level rows are the new fact, and each one names its tool. The fourth is the - // one that spent the budget: the framework does not capture that exception at all, so it never - // reaches CreateResponseMessages, and only the invocation hook sees it. - AuditEvent[] conversations = [.. failures.Where(failure => failure.Payload.ContainsKey(AuditPayloadKeys.ToolCallId))]; - Assert.Equal(4, conversations.Length); - Assert.All(conversations, conversation => Assert.Equal("lookup_order", conversation.Payload[AuditPayloadKeys.ToolName])); - Assert.All( - conversations, - conversation => Assert.Equal( - ToolFailureKinds.ToToken(ToolFailureKind.Faulted), - conversation.Payload[AuditPayloadKeys.ToolFailureKind])); - Assert.All( - conversations, - conversation => Assert.Contains( - ThrowingToolBuilder.Message, - conversation.Payload[AuditPayloadKeys.ToolError], - StringComparison.Ordinal)); - - // Four conversations, four ids, and none of them repeated. The name alone could never have told them - // apart, and the chain used to hold neither. - string[] ids = [.. conversations.Select(conversation => conversation.Payload[AuditPayloadKeys.ToolCallId])]; - Assert.Equal(4, ids.Distinct(StringComparer.Ordinal).Count()); - - // The chain still verifies over every one of the new keys. - Assert.All(events, AuditEventVocabulary.Validate); - } - - // ------------------------------------------------------------------------------------------- - // The facts of one tool call. §8.7 row six is the turn-level fact; these are the conversation-level ones. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task AToolThatFaults_NamesTheToolAndTheConversationIdInTheChain() - { - // One failing round, then the model answers, so the turn ends normally and the only thing - // that could ever have recorded the tool is the observer. - using NamedToolCallingChatClient reply = new("lookup_order", "I could not reach the order system."); - InMemoryAuditSink sink = new(); - UnreachableEndpointToolBuilder tools = new(); - ConversationSession session = Build(ToolYaml, reply, null, tools.Create, auditSink: sink).Create("conversation-1"); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - AuditEvent failed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); - Assert.Equal("lookup_order", failed.Payload[AuditPayloadKeys.ToolName]); - Assert.Equal(reply.CallIds[0], failed.Payload[AuditPayloadKeys.ToolCallId]); - Assert.Equal( - ToolFailureKinds.ToToken(ToolFailureKind.Faulted), - failed.Payload[AuditPayloadKeys.ToolFailureKind]); - Assert.Contains( - UnreachableEndpointToolBuilder.Message, - failed.Payload[AuditPayloadKeys.ToolError], - StringComparison.Ordinal); - - // One failure is under the budget, so the turn is an ordinary turn that spoke. - Assert.Null(turn.Failure); - Assert.Equal(0, failed.TurnIndex); - } - - [Fact] - public async Task AHallucinatedToolName_ReachesTheChain() - { - // The model invented the name. The framework answers it with a message and NO exception, so - // it spends none of the error budget and the turn goes on — which is correct, and is exactly - // why nothing else in the system would ever have recorded that it happened. - using NamedToolCallingChatClient reply = new("lookup_ordar", "Let me try that again."); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(ToolYaml, reply, null, new StubToolBuilder(/*lang=json,strict*/ """{"status":"shipped"}""").Create, auditSink: sink) - .Create("conversation-1"); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - AuditEvent failed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); - - // The name recorded is the name the MODEL called, so a reader that joins it to tools[].id - // finds nothing — which is the finding. - Assert.Equal("lookup_ordar", failed.Payload[AuditPayloadKeys.ToolName]); - Assert.Equal(reply.CallIds[0], failed.Payload[AuditPayloadKeys.ToolCallId]); - Assert.Equal( - ToolFailureKinds.ToToken(ToolFailureKind.Undeclared), - failed.Payload[AuditPayloadKeys.ToolFailureKind]); - - // The turn is unharmed: no exception, no budget spent, and the caller heard a real reply. - Assert.Null(turn.Failure); - Assert.Equal("Let me try that again.", turn.ReplyText); - } - - [Fact] - public async Task TwoParallelCallsToTheSameTool_AreTwoDistinguishableRecords() - { - // One assistant message, two calls, one tool name. Without the call id these are one fact - // written twice, and a reader cannot tell which call failed. - using NamedToolCallingChatClient reply = new("lookup_order", "Both lookups failed.", callsPerTurn: 2); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(ToolYaml, reply, null, new UnreachableEndpointToolBuilder().Create, auditSink: sink) - .Create("conversation-1"); - - _ = await session.RunTurnAsync("where are my two orders", TestContext.Current.CancellationToken); - - AuditEvent[] failures = [.. sink.EventsOf("conversation-1").Where(item => item.Kind == AuditEventKind.ToolFailed)]; - - Assert.Equal(2, failures.Length); - Assert.All(failures, failure => Assert.Equal("lookup_order", failure.Payload[AuditPayloadKeys.ToolName])); - Assert.Equal( - reply.CallIds.ToArray(), - failures.Select(failure => failure.Payload[AuditPayloadKeys.ToolCallId]).Order(StringComparer.Ordinal).ToArray()); - - // Two records, and the chain still verifies over them. - Assert.All(sink.EventsOf("conversation-1"), AuditEventVocabulary.Validate); - } - - [Fact] - public async Task AToolWhoseFaultTheModelCanAnswer_WritesNoToolFailedEventAtAll() - { - // The converged design, from the chain's side: the tool ANSWERED. The framework sees a result - // and no exception, so nothing failed as far as it is concerned and no row is written. - using NamedToolCallingChatClient reply = new("lookup_order", "That order is already closed."); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(ToolYaml, reply, null, new RefusedRequestToolBuilder().Create, auditSink: sink) - .Create("conversation-1"); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - Assert.DoesNotContain(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); - Assert.Null(turn.Failure); - Assert.Equal("That order is already closed.", turn.ReplyText); - } - - /// T44: one compiled agent serves every conversation, so nothing per conversation may live on it. - [Fact] - public async Task TwoConversationsAtOnce_DoNotPolluteEachOthersRecords() - { - const int FanOut = 8; - InMemoryAuditSink sink = new(); - - // One factory, one document, one compiled agent behind the sessions — the shape T44 pins. - using NamedToolCallingChatClient reply = new("lookup_order", "I could not reach the order system."); - ConversationSessionFactory factory = Build(ToolYaml, reply, null, new UnreachableEndpointToolBuilder().Create, auditSink: sink); - - CancellationToken token = TestContext.Current.CancellationToken; - using Barrier gate = new(FanOut); - - await Task.WhenAll(Enumerable.Range(0, FanOut).Select(index => Task.Run( - async () => - { - ConversationSession session = factory.Create($"conversation-{index}"); - gate.SignalAndWait(token); - _ = await session.RunTurnAsync("where is my order", token).ConfigureAwait(false); - }, - token))); - - for (int index = 0; index < FanOut; index++) - { - IReadOnlyList events = sink.EventsOf($"conversation-{index}"); - - // Each conversation recorded exactly its own failure, under its own id, and its three events kept - // their own order. A record that had leaked between two flows would show up as a second - // tool.failed here or as one of these kinds out of place. - AuditEvent failed = Assert.Single(events, item => item.Kind == AuditEventKind.ToolFailed); - Assert.Equal("lookup_order", failed.Payload[AuditPayloadKeys.ToolName]); - Assert.NotEmpty(failed.Payload[AuditPayloadKeys.ToolCallId]); - Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.ToolFailed, AuditEventKind.TurnCompleted], - events.Select(item => item.Kind).ToArray()); - Assert.All(events, AuditEventVocabulary.Validate); - } - } - - [Fact] - public async Task ATerminalStage_ClosesTheChainWithOneConversationEndedEvent() - { - using SequencedChatClient reply = new("goodbye."); - using SequencedChatClient fill = new(SaidGoodbye); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - _ = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); - - Assert.True(session.IsComplete); - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); - Assert.Null(ended.TurnIndex); - - // The reason is one token of the closed set, so a report can count it. The terminal stage is - // detail beside it, and it is not the reason. - Assert.Equal("agent.completed", ended.Payload[AuditPayloadKeys.EndReason]); - Assert.Equal("close", ended.Payload[AuditPayloadKeys.StageAfter]); - - // A hang-up frame that arrives after the machine already closed the conversation writes nothing. - Assert.False(session.EndConversation(ConversationEndReason.CallerHungUp)); - } - - [Fact] - public void AHostThatEndsTheConversation_ClosesTheChainOnce() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - Assert.True(session.EndConversation(ConversationEndReason.CallerHungUp)); - Assert.False(session.EndConversation(ConversationEndReason.CallerHungUp)); - - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); - Assert.Equal("caller.hangup", ended.Payload[AuditPayloadKeys.EndReason]); - - // The machine never ran, so no stage closed the conversation and no stage rides on the event. - Assert.False(ended.Payload.ContainsKey(AuditPayloadKeys.StageAfter)); - Assert.True(session.IsComplete); - } - - /// Section 11, item 5: the conversation goes to a human through the conference pattern. - [Fact] - public void ATransferToAHuman_ClosesTheChainWithItsOwnReason() - { - using SequencedChatClient reply = new("one moment please."); - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - // The adapter joins the conversation to a conference and never sends the transfer command (T27). - Assert.True(session.EndConversation(ConversationEndReason.TransferredToHuman)); - - AuditEvent ended = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); - Assert.Equal("agent.transferred", ended.Payload[AuditPayloadKeys.EndReason]); - } - - [Fact] - public void AReasonOutsideTheClosedSet_EndsNoConversation() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - _ = Assert.Throws(() => session.EndConversation((ConversationEndReason)99)); - - // Nothing moved. The conversation still runs, and the chain still holds only its first event. - Assert.False(session.IsComplete); - Assert.DoesNotContain(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ConversationEnded); - } - - // ------------------------------------------------------------------------------------------- - // Item 6: the chain the events form verifies. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheEventsOfOneConversation_FormAChainThatVerifies() - { - using SequencedChatClient reply = new("hello there.", "goodbye."); - using SequencedChatClient fill = new(StayingNull, SaidGoodbye); - InMemoryAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - CancellationToken token = TestContext.Current.CancellationToken; - _ = await session.RunTurnAsync("hi", token); - _ = await session.RunTurnAsync("goodbye", token); - - IReadOnlyList events = sink.EventsOf("conversation-1"); - - // Four facts, four identities, none of them repeated. What orders them is - // audit_event.sequence, which the store assigns and which this test never sees. - Assert.Equal(4, events.Count); - Assert.Equal(events.Count, events.Select(item => item.EventId).Distinct().Count()); - - // This is chain_check of section 11, item 6, run over what the turn loop produced. - Assert.All(events, AuditEventVocabulary.Validate); - } - - // ------------------------------------------------------------------------------------------- - // D23: the write never sits on the turn. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheTurn_FinishesWhileTheAppendIsStillOpen() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - BlockingAuditSink sink = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: sink).Create("conversation-1"); - - // Section 7: a durable insert costs 13 ms at p50 and 32 ms at p99, against 91 nanoseconds to - // enqueue. The turn therefore finishes with no append complete at all. - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Assert.Equal("hello there.", turn.ReplyText); - - // The whole turn ran and returned while conversation.started is still inside the sink. The - // turn.completed event is queued behind it rather than racing past it, because the events of - // one conversation reach an observer in the order the conversation raised them, so exactly one has arrived. - _ = Assert.Single(sink.Events); - - sink.Release(); - - // Off the turn, and only now. The queued event lands on the dispatcher's own task, which is - // where a slow sink is paid for. - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); - while (sink.Events.Count < 2 && DateTimeOffset.UtcNow < deadline) - { - await Task.Delay(10, TestContext.Current.CancellationToken); - } - - Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], - sink.Events.Select(item => item.Kind).ToArray()); - } - - [Fact] - public async Task ASinkThatRefusesAnEvent_IsLoggedAndTheTurnGoesOn() - { - RecordingLogger logger = new(); - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: new ThrowingAuditSink(), logger: logger) - .Create("conversation-1"); - - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // Audit is a record of the conversation and never a part of it. - Assert.Equal("hello there.", turn.ReplyText); - Assert.NotEmpty(logger.Of(5)); - Assert.All(logger.Of(5), line => Assert.Equal(LogLevel.Error, line.Level)); - } - - [Fact] - public async Task ASessionWithNoSink_RunsATurnAndThrowsNothing() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-1"); - - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Assert.Equal("hello there.", turn.ReplyText); - } - - // ------------------------------------------------------------------------------------------- - // The in-memory sink. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheInMemorySink_KeepsTheConversationsApart() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - InMemoryAuditSink sink = new(); - ConversationSessionFactory factory = Build(PolicyYaml, reply, fill, auditSink: sink); - - ConversationSession first = factory.Create("conversation-1"); - ConversationSession second = factory.Create("conversation-2"); - _ = await first.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // One sink serves every conversation, because a session names itself on every event. - Assert.Equal(2, sink.EventsOf("conversation-1").Count); - _ = Assert.Single(sink.EventsOf("conversation-2")); - Assert.Equal(3, sink.Events.Count); - Assert.Equal("conversation-2", second.ConversationId); - } - - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- - private static ConversationSessionFactory Build( - string yaml, - IChatClient reply, - IChatClient? fill, - Func? tools = null, - TimeProvider? timeProvider = null, - IAuditSinkPort? auditSink = null, - ILogger? logger = null) - { - // There is always a sink now: ConversationObservers.Standard takes a required one, because the - // composition root resolves providers.audit for every host and falls back to the in-process - // memory kind. An optional parameter has to be a compile-time constant, so the default is - // spelled here instead — a fact that does not care where its events land gets a fresh - // in-memory sink and reads exactly as it did when it passed nothing. - IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); - - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - RoutingChatClientFactory chatClients = new(reply); - if (fill is not null) - { - _ = chatClients.Route("fill", fill); - } - - CompiledAgent compiled = ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(chatClients) - { - Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), - })["main"]; - - return new ConversationSessionFactory( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, chatClients), - timeProvider, - logger, - ConversationObservers.Standard(sink, logger)); - } - } -} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs new file mode 100644 index 00000000..98961006 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionAuditToolFailureTests.cs @@ -0,0 +1,212 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using Xunit; +using static AgentCore.Application.Tests.Audit.ConversationSessionAuditTestSupport; + +namespace AgentCore.Application.Tests.Audit +{ + /// §8.7 row six is the turn-level fact of a tool failure; these are the conversation-level ones. + public sealed class ConversationSessionAuditToolFailureTests + { + [Fact] + public async Task TheFourthConsecutiveToolFailure_WritesAToolFailedEventBeforeTheTurnEvent() + { + using LoopingToolCallingChatClient reply = new(); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(ToolYaml, reply, null, new ThrowingToolBuilder().Create, auditSink: sink).Create("conversation-1"); + + _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + IReadOnlyList events = sink.EventsOf("conversation-1"); + AuditEvent[] failures = [.. events.Where(item => item.Kind == AuditEventKind.ToolFailed)]; + AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); + + // The kind is raised at two altitudes and both are here: one row for each conversation that failed, + // and one for the turn that ended because of them. Five in all, and every one of them before + // the turn event. + Assert.Equal(5, failures.Length); + List order = [.. sink.EventsOf("conversation-1")]; + Assert.All(failures, failure => Assert.True(order.IndexOf(failure) < order.IndexOf(completed))); + Assert.All(failures, failure => Assert.Equal(0, failure.TurnIndex)); + + // The turn-level row is the one section 8.7 row six has always written, and it is unchanged: + // no tool is named, because the fault reaches the session with no function name on it, and a + // missing fact is an absent key rather than the word "unknown". + AuditEvent turnLevel = Assert.Single( + failures, + failure => !failure.Payload.ContainsKey(AuditPayloadKeys.ToolCallId)); + Assert.False(turnLevel.Payload.ContainsKey(AuditPayloadKeys.ToolName)); + Assert.Contains( + ThrowingToolBuilder.Message, + turnLevel.Payload[AuditPayloadKeys.ToolError], + StringComparison.Ordinal); + + // The four call-level rows are the new fact, and each one names its tool. The fourth is the + // one that spent the budget: the framework does not capture that exception at all, so it never + // reaches CreateResponseMessages, and only the invocation hook sees it. + AuditEvent[] conversations = [.. failures.Where(failure => failure.Payload.ContainsKey(AuditPayloadKeys.ToolCallId))]; + Assert.Equal(4, conversations.Length); + Assert.All(conversations, conversation => Assert.Equal("lookup_order", conversation.Payload[AuditPayloadKeys.ToolName])); + Assert.All( + conversations, + conversation => Assert.Equal( + ToolFailureKinds.ToToken(ToolFailureKind.Faulted), + conversation.Payload[AuditPayloadKeys.ToolFailureKind])); + Assert.All( + conversations, + conversation => Assert.Contains( + ThrowingToolBuilder.Message, + conversation.Payload[AuditPayloadKeys.ToolError], + StringComparison.Ordinal)); + + // Four conversations, four ids, and none of them repeated. The name alone could never have told them + // apart. + string[] ids = [.. conversations.Select(conversation => conversation.Payload[AuditPayloadKeys.ToolCallId])]; + Assert.Equal(4, ids.Distinct(StringComparer.Ordinal).Count()); + + // The chain still verifies over every one of the new keys. + Assert.All(events, AuditEventVocabulary.Validate); + } + + [Fact] + public async Task AToolThatFaults_NamesTheToolAndTheConversationIdInTheChain() + { + // One failing round, then the model answers, so the turn ends normally and the only thing + // that could ever have recorded the tool is the observer. + using NamedToolCallingChatClient reply = new("lookup_order", "I could not reach the order system."); + InMemoryAuditSink sink = new(); + UnreachableEndpointToolBuilder tools = new(); + ConversationSession session = Build(ToolYaml, reply, null, tools.Create, auditSink: sink).Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + AuditEvent failed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); + Assert.Equal("lookup_order", failed.Payload[AuditPayloadKeys.ToolName]); + Assert.Equal(reply.CallIds[0], failed.Payload[AuditPayloadKeys.ToolCallId]); + Assert.Equal( + ToolFailureKinds.ToToken(ToolFailureKind.Faulted), + failed.Payload[AuditPayloadKeys.ToolFailureKind]); + Assert.Contains( + UnreachableEndpointToolBuilder.Message, + failed.Payload[AuditPayloadKeys.ToolError], + StringComparison.Ordinal); + + // One failure is under the budget, so the turn is an ordinary turn that spoke. + Assert.Null(turn.Failure); + Assert.Equal(0, failed.TurnIndex); + } + + [Fact] + public async Task AHallucinatedToolName_ReachesTheChain() + { + // The model invented the name. The framework answers it with a message and NO exception, so + // it spends none of the error budget and the turn goes on — which is correct, and is exactly + // why nothing else in the system would ever have recorded that it happened. + using NamedToolCallingChatClient reply = new("lookup_ordar", "Let me try that again."); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(ToolYaml, reply, null, new StubToolBuilder(/*lang=json,strict*/ """{"status":"shipped"}""").Create, auditSink: sink) + .Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + AuditEvent failed = Assert.Single(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); + + // The name recorded is the name the MODEL called, so a reader that joins it to tools[].id + // finds nothing — which is the finding. + Assert.Equal("lookup_ordar", failed.Payload[AuditPayloadKeys.ToolName]); + Assert.Equal(reply.CallIds[0], failed.Payload[AuditPayloadKeys.ToolCallId]); + Assert.Equal( + ToolFailureKinds.ToToken(ToolFailureKind.Undeclared), + failed.Payload[AuditPayloadKeys.ToolFailureKind]); + + // The turn is unharmed: no exception, no budget spent, and the caller heard a real reply. + Assert.Null(turn.Failure); + Assert.Equal("Let me try that again.", turn.ReplyText); + } + + [Fact] + public async Task TwoParallelCallsToTheSameTool_AreTwoDistinguishableRecords() + { + // One assistant message, two calls, one tool name. Without the call id these are one fact + // written twice, and a reader cannot tell which call failed. + using NamedToolCallingChatClient reply = new("lookup_order", "Both lookups failed.", callsPerTurn: 2); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(ToolYaml, reply, null, new UnreachableEndpointToolBuilder().Create, auditSink: sink) + .Create("conversation-1"); + + _ = await session.RunTurnAsync("where are my two orders", TestContext.Current.CancellationToken); + + AuditEvent[] failures = [.. sink.EventsOf("conversation-1").Where(item => item.Kind == AuditEventKind.ToolFailed)]; + + Assert.Equal(2, failures.Length); + Assert.All(failures, failure => Assert.Equal("lookup_order", failure.Payload[AuditPayloadKeys.ToolName])); + Assert.Equal( + reply.CallIds.ToArray(), + failures.Select(failure => failure.Payload[AuditPayloadKeys.ToolCallId]).Order(StringComparer.Ordinal).ToArray()); + + // Two records, and the chain still verifies over them. + Assert.All(sink.EventsOf("conversation-1"), AuditEventVocabulary.Validate); + } + + [Fact] + public async Task AToolWhoseFaultTheModelCanAnswer_WritesNoToolFailedEventAtAll() + { + // The converged design, from the chain's side: the tool ANSWERED. The framework sees a result + // and no exception, so nothing failed as far as it is concerned and no row is written. + using NamedToolCallingChatClient reply = new("lookup_order", "That order is already closed."); + InMemoryAuditSink sink = new(); + ConversationSession session = Build(ToolYaml, reply, null, new RefusedRequestToolBuilder().Create, auditSink: sink) + .Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + Assert.DoesNotContain(sink.EventsOf("conversation-1"), item => item.Kind == AuditEventKind.ToolFailed); + Assert.Null(turn.Failure); + Assert.Equal("That order is already closed.", turn.ReplyText); + } + + /// T44: one compiled agent serves every conversation, so nothing per conversation may live on it. + [Fact] + public async Task TwoConversationsAtOnce_DoNotPolluteEachOthersRecords() + { + const int FanOut = 8; + InMemoryAuditSink sink = new(); + + // One factory, one document, one compiled agent behind the sessions — the shape T44 pins. + using NamedToolCallingChatClient reply = new("lookup_order", "I could not reach the order system."); + ConversationSessionFactory factory = Build(ToolYaml, reply, null, new UnreachableEndpointToolBuilder().Create, auditSink: sink); + + CancellationToken token = TestContext.Current.CancellationToken; + using Barrier gate = new(FanOut); + + await Task.WhenAll(Enumerable.Range(0, FanOut).Select(index => Task.Run( + async () => + { + ConversationSession session = factory.Create($"conversation-{index}"); + gate.SignalAndWait(token); + _ = await session.RunTurnAsync("where is my order", token).ConfigureAwait(false); + }, + token))); + + for (int index = 0; index < FanOut; index++) + { + IReadOnlyList events = sink.EventsOf($"conversation-{index}"); + + // Each conversation recorded exactly its own failure, under its own id, and its three events kept + // their own order. A record that had leaked between two flows would show up as a second + // tool.failed here or as one of these kinds out of place. + AuditEvent failed = Assert.Single(events, item => item.Kind == AuditEventKind.ToolFailed); + Assert.Equal("lookup_order", failed.Payload[AuditPayloadKeys.ToolName]); + Assert.NotEmpty(failed.Payload[AuditPayloadKeys.ToolCallId]); + Assert.Equal( + [AuditEventKind.ConversationStarted, AuditEventKind.ToolFailed, AuditEventKind.TurnCompleted], + events.Select(item => item.Kind).ToArray()); + Assert.All(events, AuditEventVocabulary.Validate); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs index 9fd33f85..cf3bc787 100644 --- a/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionModerationTests.cs @@ -7,6 +7,7 @@ using AgentCore.Application.Evaluation; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Diagnostics; using AgentCore.Application.Tests.Evaluation.Fakes; using AgentCore.Application.Tests.Runtime; @@ -68,9 +69,7 @@ public sealed class ConversationSessionModerationTests agent: only """; - // ------------------------------------------------------------------------------------------- // A flagged prompt. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AFlaggedPrompt_WritesTheFlagBeforeTheTurnEvent() { @@ -204,9 +203,7 @@ public async Task TheEventsOfAModeratedConversation_FormAChainThatVerifies() Assert.All(events, AuditEventVocabulary.Validate); } - // ------------------------------------------------------------------------------------------- // A clean prompt, and the fail-open rule. - // ------------------------------------------------------------------------------------------- [Fact] public async Task ACleanPrompt_ReachesTheModelAndWritesNoFlag() { @@ -297,9 +294,7 @@ public async Task ASessionWithNoModerator_RunsATurnAndWritesNoFlag() Assert.Equal(2, sink.EventsOf("conversation-1").Count); } - // ------------------------------------------------------------------------------------------- // The streaming path takes the same decision. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AFlaggedPrompt_StreamsTheRefusalAndOpensNoModelStream() { @@ -311,6 +306,12 @@ public async Task AFlaggedPrompt_StreamsTheRefusalAndOpensNoModelStream() List spoken = []; await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("...", TestContext.Current.CancellationToken)) { + // The trailing update is the turn's committed ids (design section 6, step E5), not text. + if (update.Contents.OfType().Any()) + { + continue; + } + spoken.Add(update.Text); } @@ -333,9 +334,7 @@ public async Task ACleanPromptOnTheStreamingPath_ReachesTheModel() Assert.Equal(1, model.Calls); } - // ------------------------------------------------------------------------------------------- // Helpers. - // ------------------------------------------------------------------------------------------- private static ConversationSessionFactory Build( string yaml, IChatClient reply, @@ -344,11 +343,10 @@ private static ConversationSessionFactory Build( ILogger? logger = null, ScriptedModerationEvaluator? moderation = null) { - // There is always a sink now: ConversationObservers.Standard takes a required one, because the - // composition root resolves providers.audit for every host and falls back to the in-process - // memory kind. An optional parameter has to be a compile-time constant, so the default is - // spelled here instead — a fact that does not care where its events land gets a fresh - // in-memory sink and reads exactly as it did when it passed nothing. + // ConversationObservers.Standard takes a required sink, because the composition root resolves + // providers.audit for every host and falls back to the in-process memory kind. An optional + // parameter has to be a compile-time constant, so the default is spelled here instead — a fact + // that does not care where its events land gets a fresh in-memory sink. IAuditSinkPort auditSink = sink ?? new InMemoryAuditSink(); AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); diff --git a/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs b/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs new file mode 100644 index 00000000..62215567 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/ConversationSessionMultiStepAuditEventsTests.cs @@ -0,0 +1,179 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Audit +{ + /// + /// covers the whole reply, not only its last step. + /// + public sealed class ConversationSessionMultiStepAuditEventsTests + { + [Fact] + public async Task ASingleStepTurn_HashesTheOneStepItSpoke() + { + using SequencedChatClient reply = new("hello there."); + InMemoryAuditSink sink = new(); + ConversationSession session = InterruptionSessions.CreateSession( + InterruptionSessions.NoToolYaml, reply, auditSink: sink); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + AuditEvent completed = Assert.Single( + sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnCompleted); + Assert.Equal( + "92ea4f7debacecc2bb655776bd3406477c3c75d2575d40e3d9d67e763b5d5cec", + completed.Payload[AuditPayloadKeys.ReplyTextSha256]); + } + + [Fact] + public async Task AMultiStepTurn_HashesEveryStepInOrder() + { + // The model announces the lookup in prose on the same message as its call, then answers once + // the result is in. The hash covers both steps, not only "the price is fifty". + ProseBesideToolChatClient client = new("the price is fifty"); + client.OpenGate(); + InMemoryAuditSink sink = new(); + ConversationSession session = InterruptionSessions.CreateSession( + InterruptionSessions.ToolYaml, + client, + new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create, + sink); + + _ = await session.RunTurnAsync("what does it cost", TestContext.Current.CancellationToken); + + AuditEvent completed = Assert.Single( + sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnCompleted); + Assert.Equal( + "0aeee3157d498f34407043e72714af88cbb07c27dc76050b8a294e7b96a6df9b", + completed.Payload[AuditPayloadKeys.ReplyTextSha256]); + } + + [Fact] + public async Task ACutMultiStepTurn_HashesTheGeneratedReplyAndTheHeardTextSeparately() + { + // The barge-in is reported after the turn already ran to completion (D28, item 6a), so the + // model's own output is fixed and deterministic: the whole two-step reply. The relay's report + // of what the caller heard is a separate, independent value. turn.completed proves the first; + // reply.interrupted proves the second, and the two must not be conflated into one hash. + ProseBesideToolChatClient client = new("the total comes to eighty"); + client.OpenGate(); + InMemoryAuditSink sink = new(); + ConversationSession session = InterruptionSessions.CreateSession( + InterruptionSessions.ToolYaml, + client, + new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create, + sink); + + _ = await session.RunTurnAsync("what does it cost", TestContext.Current.CancellationToken); + Assert.True(session.Cut( + 0, new TurnCut(ProseBesideToolChatClient.Prose + "the total", TimeSpan.FromMilliseconds(1820)))); + + IReadOnlyList events = sink.EventsOf(session.ConversationId); + AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); + AuditEvent interrupted = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); + + Assert.Equal( + "2321bd05672f296a576e169190756dce4be21568a9d3d0542d45375ad056a7d8", + completed.Payload[AuditPayloadKeys.ReplyTextSha256]); + Assert.Equal( + "5b79609c62265cd562cd5e43a658f62e704bf8a3342810fd576640da7f25591a", + interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + } + + [Fact] + public async Task AToolThatSpendsItsBudgetAfterTheModelSpoke_HashesTheStepsAndTheFallbackTheRowsHold() + { + ProseThenFailingToolChatClient client = new(); + InMemoryAuditSink sink = new(); + ConversationSession session = InterruptionSessions.CreateSession( + InterruptionSessions.ToolYaml, client, new ThrowingToolBuilder().Create, sink); + + TurnResult turn = await session.RunTurnAsync("what does it cost", TestContext.Current.CancellationToken); + + Assert.NotNull(turn.Failure); + Assert.Equal( + "Checking 1. Checking 2. Checking 3. Checking 4.I am sorry. I could not finish that. Please say it again.", + await SpokenAsync(session, turnIndex: 0)); + AuditEvent completed = Assert.Single( + sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnCompleted); + Assert.Equal( + "40e79eac3b0c25fe5d27e0fd6e0ee02aecc48ff93b81af341e0ec3cdb875812d", + completed.Payload[AuditPayloadKeys.ReplyTextSha256]); + } + + [Fact] + public async Task AReaderThatLeavesDuringTheSecondStep_HashesEveryStepItWasShown() + { + ProseBesideToolChatClient client = new("the price is fifty"); + InMemoryAuditSink sink = new(); + ConversationSession session = InterruptionSessions.CreateSession( + InterruptionSessions.ToolYaml, + client, + new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create, + sink); + + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("what does it cost", TestContext.Current.CancellationToken)) + { + if (update.Text == "the") + { + break; + } + } + + Assert.Equal("Let me check that for youthe", await SpokenAsync(session, turnIndex: 0)); + Assert.Equal("Let me check that for youthe", session.LastTurn?.ReplyText); + AuditEvent interrupted = Assert.Single( + sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + Assert.Equal( + "7b1485a90a236eb1d3a77c433b1e9371d93481283ab337b76b59c3c059bc1d51", + interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + } + + [Fact] + public async Task ALateCutThatNamesNoText_KeepsEveryStepAndHashesThem() + { + ProseBesideToolChatClient client = new("the total comes to eighty"); + client.OpenGate(); + InMemoryAuditSink sink = new(); + ConversationSession session = InterruptionSessions.CreateSession( + InterruptionSessions.ToolYaml, + client, + new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create, + sink); + + _ = await session.RunTurnAsync("what does it cost", TestContext.Current.CancellationToken); + Assert.True(session.Cut(0, new TurnCut(ShownText: null, TimeSpan.FromMilliseconds(900)))); + + Assert.Equal("Let me check that for youthe total comes to eighty", await SpokenAsync(session, turnIndex: 0)); + AuditEvent interrupted = Assert.Single( + sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + Assert.Equal( + "2321bd05672f296a576e169190756dce4be21568a9d3d0542d45375ad056a7d8", + interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + } + + /// + /// Rebuilds one turn's words the way the store's verify query does: the text parts of every assistant row + /// that carries one, in ordinal order, with no separator. + /// + private static async Task SpokenAsync(ConversationSession session, int turnIndex) + { + await session.FlushTranscriptAsync(); + IReadOnlyList rows = await session.Compiled.ConversationStore + .ReadForSessionAsync(session.ConversationId, TestContext.Current.CancellationToken); + + return string.Concat(rows + .Where(row => row.TurnIndex == turnIndex && row.CoversUpTo is null && row.Content.Role == ChatRole.Assistant) + .OrderBy(row => row.Ordinal) + .SelectMany(row => row.Content.Contents.OfType()) + .Select(text => text.Text)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/FakeDbException.cs b/tests/AgentCore.Application.Tests/Audit/FakeDbException.cs new file mode 100644 index 00000000..89c6ca90 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/FakeDbException.cs @@ -0,0 +1,12 @@ +using System.Data.Common; + +namespace AgentCore.Application.Tests.Audit +{ + /// + /// A database fault that says whether it is transient, the way a provider such as Npgsql does. + /// + internal sealed class FakeDbException(bool transient) : DbException(transient ? "the store blinked." : "the store refused.") + { + public override bool IsTransient => transient; + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs b/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs new file mode 100644 index 00000000..575e3688 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/QueuedAuditSinkRetryTests.cs @@ -0,0 +1,139 @@ +using AgentCore.Application.Audit; +using AgentCore.Application.Tests.Diagnostics; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Application.Tests.Audit +{ + /// + /// What the queue does with a batch the store refuses: a transient fault is retried, anything else is not. + /// + public sealed class QueuedAuditSinkRetryTests + { + /// The id Log.AuditAppendFailed carries. + private const int AuditAppendFailedEventId = 5; + + private static readonly TimeSpan PastAnyRetryWait = TimeSpan.FromMinutes(1); + + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly RecordingLogger _logger = new(); + + [Fact] + public async Task ABatchRefusedOnceWithATransientFault_IsWrittenOnce_AndBeforeEverythingAfterIt() + { + RefusingAuditSink inner = new((_, attempt) => attempt == 1 ? new FakeDbException(transient: true) : null); + await using QueuedAuditSink sink = new(inner, _logger, timeProvider: _time); + AuditEvent first = Event(); + AuditEvent second = Event(); + + await sink.AppendAsync(first, TestContext.Current.CancellationToken); + await inner.NextAttemptAsync(); + await RetryWaitArmedAsync(); + + // Queued while the refused batch waits for its retry, so it may only land after it. + await sink.AppendAsync(second, TestContext.Current.CancellationToken); + + // The wait is on the injected clock: until it moves, nothing is tried again. + _ = Assert.Single(inner.Attempts); + + _time.Advance(PastAnyRetryWait); + await sink.FlushAsync(TestContext.Current.CancellationToken); + + Assert.Equal([first.EventId, second.EventId], inner.Events); + Assert.Empty(_logger.Of(AuditAppendFailedEventId)); + } + + [Fact] + public async Task ABatchRefusedEveryTime_IsDroppedAfterThreeRetries_WithOneError_AndLaterBatchesStillLand() + { + AuditEvent doomed = Event(); + AuditEvent later = Event(); + RefusingAuditSink inner = new((batch, _) => + batch.Any(item => item.EventId == doomed.EventId) ? new FakeDbException(transient: true) : null); + await using QueuedAuditSink sink = new(inner, _logger, timeProvider: _time); + + await sink.AppendAsync(doomed, TestContext.Current.CancellationToken); + await inner.NextAttemptAsync(); + + for (int retry = 1; retry <= 3; retry++) + { + await RetryWaitArmedAsync(); + _time.Advance(PastAnyRetryWait); + await inner.NextAttemptAsync(); + } + + await sink.AppendAsync(later, TestContext.Current.CancellationToken); + await sink.FlushAsync(TestContext.Current.CancellationToken); + + Assert.Equal(4, inner.Attempts.Count(batch => batch.Contains(doomed.EventId))); + LogLine line = Assert.Single(_logger.Of(AuditAppendFailedEventId)); + _ = Assert.IsType(line.Exception); + Assert.Equal([later.EventId], inner.Events); + } + + [Theory] + [InlineData("malformed")] + [InlineData("permanent-db")] + [InlineData("other")] + public async Task ABatchRefusedPermanently_IsDroppedAtOnce_WithoutARetry(string refusal) + { + Exception cause = refusal switch + { + "malformed" => new ArgumentException("The ending names no reason."), + "permanent-db" => new FakeDbException(transient: false), + _ => new InvalidOperationException("the audit store is unreachable."), + }; + RefusingAuditSink inner = new((_, _) => cause); + await using QueuedAuditSink sink = new(inner, _logger, timeProvider: _time); + + await sink.AppendAsync(Event(), TestContext.Current.CancellationToken); + + // Completes with the clock never moved, because there is no wait to move it past. + await sink.FlushAsync(TestContext.Current.CancellationToken) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + _ = Assert.Single(inner.Attempts); + Assert.Same(cause, Assert.Single(_logger.Of(AuditAppendFailedEventId)).Exception); + } + + [Fact] + public async Task DisposalDuringARetryWait_EndsTheWaitWithinTheWindow_AndDropsTheBatchOnce() + { + RefusingAuditSink inner = new((_, _) => new FakeDbException(transient: true)); + QueuedAuditSink sink = new(inner, _logger, timeProvider: _time); + + await sink.AppendAsync(Event(), TestContext.Current.CancellationToken); + await inner.NextAttemptAsync(); + await RetryWaitArmedAsync(); + + // The clock never moves, so the wait only ends because disposal cancels it after its window. + await sink.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + _time.Advance(PastAnyRetryWait); + + _ = Assert.Single(inner.Attempts); + LogLine line = Assert.Single(_logger.Of(AuditAppendFailedEventId)); + _ = Assert.IsAssignableFrom(line.Exception); + Assert.Empty(inner.Events); + } + + /// Waits until the queue has armed its wait before the next attempt. + private Task RetryWaitArmedAsync() + { + return _time.WaitForTimersAsync(1).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + + private static AuditEvent Event() + { + return new() + { + ConversationId = "conversation-1", + EventId = Guid.CreateVersion7(), + Kind = AuditEventKind.TurnCompleted, + OccurredAt = DateTimeOffset.UnixEpoch, + }; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Audit/RefusingAuditSink.cs b/tests/AgentCore.Application.Tests/Audit/RefusingAuditSink.cs new file mode 100644 index 00000000..864a1e86 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Audit/RefusingAuditSink.cs @@ -0,0 +1,80 @@ +using System.Threading.Channels; +using AgentCore.Application.Ports; +using AgentCore.Domain.Audit; +using Xunit; + +namespace AgentCore.Application.Tests.Audit +{ + /// + /// A batch-taking sink that refuses whichever attempts a test tells it to, and records every attempt. + /// + /// + /// Given the batch and the one-based number of the attempt across the sink's life, returns the exception + /// to refuse it with, or to accept it. + /// + internal sealed class RefusingAuditSink(Func, int, Exception?> refuse) : IAuditSinkPort + { + private readonly Lock _gate = new(); + private readonly List _attempts = []; + private readonly List _events = []; + private readonly Channel _attempted = Channel.CreateUnbounded(); + + /// Gets the event ids of every batch the queue handed over, accepted or not, oldest first. + public IReadOnlyList Attempts + { + get + { + lock (_gate) + { + return [.. _attempts]; + } + } + } + + /// Gets the ids of the events this sink accepted, in the order they arrived. + public IReadOnlyList Events + { + get + { + lock (_gate) + { + return [.. _events.Select(item => item.EventId)]; + } + } + } + + /// Waits for the next attempt, accepted or refused, to have been made. + /// A task that completes once one more attempt was made, or times out after ten seconds. + public Task NextAttemptAsync() + { + return _attempted.Reader + .ReadAsync(TestContext.Current.CancellationToken) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + + public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) + { + return AppendManyAsync([auditEvent], cancellationToken); + } + + public ValueTask AppendManyAsync(IReadOnlyList auditEvents, CancellationToken cancellationToken = default) + { + Exception? refusal; + int attempt; + lock (_gate) + { + _attempts.Add([.. auditEvents.Select(item => item.EventId)]); + attempt = _attempts.Count; + refusal = refuse(auditEvents, attempt); + if (refusal is null) + { + _events.AddRange(auditEvents); + } + } + + _ = _attempted.Writer.TryWrite(attempt); + return refusal is null ? ValueTask.CompletedTask : ValueTask.FromException(refusal); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/ConversationProviderVoiceSchemaTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/ConversationProviderVoiceSchemaTests.cs new file mode 100644 index 00000000..3bdc2c99 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/ConversationProviderVoiceSchemaTests.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using Xunit; + +namespace AgentCore.Application.Tests.Configuration.Compilation +{ + /// + /// providers.conversation.userAway and providers.conversation.filler: the voice options + /// surface of plan step 8. A document that omits either key opens no away prompt override and no + /// filler; the shipped defaults are an concern, not this layer's. + /// + public sealed class ConversationProviderVoiceSchemaTests + { + private const string MinimalYaml = + """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + agents: + items: + - { id: only, instructions: "hello" } + entries: + main: + agent: only + """; + + [Fact] + public void AnOmittedUserAwayBindsAsUndefined() + { + ConversationProviderConfiguration conversation = Load(MinimalYaml).Providers!.Conversation!; + + Assert.Equal(JsonValueKind.Undefined, conversation.UserAway.ValueKind); + Assert.Empty(conversation.Filler); + } + + [Fact] + public void AnExplicitNullUserAwayBindsAsNull() + { + const string yaml = + """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay, userAway: null } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + agents: + items: + - { id: only, instructions: "hello" } + entries: + main: + agent: only + """; + + ConversationProviderConfiguration conversation = Load(yaml).Providers!.Conversation!; + + Assert.Equal(JsonValueKind.Null, conversation.UserAway.ValueKind); + } + + [Fact] + public void AFullUserAwayAndFillerBlockBindsEveryField() + { + const string yaml = + """ + apiVersion: agentcore/v1 + providers: + conversation: + kind: telnyx-relay + userAway: + timeoutSeconds: 30 + say: "Are you still there?" + filler: + lookup_order: + say: "One moment while I look that up." + delaySeconds: 1.5 + intervalSeconds: 5 + maxSteps: 2 + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + agents: + items: + - { id: only, instructions: "hello" } + entries: + main: + agent: only + """; + + ConversationProviderConfiguration conversation = Load(yaml).Providers!.Conversation!; + + Assert.Equal(JsonValueKind.Object, conversation.UserAway.ValueKind); + Assert.Equal(30, conversation.UserAway.GetProperty("timeoutSeconds").GetDouble()); + Assert.Equal("Are you still there?", conversation.UserAway.GetProperty("say").GetString()); + + VoiceFillerConfiguration filler = Assert.Single(conversation.Filler).Value; + Assert.Equal("One moment while I look that up.", filler.Say); + Assert.Equal(1.5, filler.DelaySeconds); + Assert.Equal(5, filler.IntervalSeconds); + Assert.Equal(2, filler.MaxSteps); + } + + [Fact] + public void AUserAwayMissingSayIsRejectedBySchemaValidation() + { + const string yaml = + """ + apiVersion: agentcore/v1 + providers: + conversation: { kind: telnyx-relay, userAway: { timeoutSeconds: 15 } } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + agents: + items: + - { id: only, instructions: "hello" } + entries: + main: + agent: only + """; + + ConfigurationLoadException failure = Assert.Throws(() => Load(yaml)); + + Assert.Contains("say", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AFillerEntryMissingDelaySecondsIsRejectedBySchemaValidation() + { + const string yaml = + """ + apiVersion: agentcore/v1 + providers: + conversation: + kind: telnyx-relay + filler: + lookup_order: + say: "One moment" + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + agents: + items: + - { id: only, instructions: "hello" } + entries: + main: + agent: only + """; + + ConfigurationLoadException failure = Assert.Throws(() => Load(yaml)); + + Assert.Contains("delaySeconds", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnUnknownKeyInsideFillerIsRejectedBySchemaValidation() + { + const string yaml = + """ + apiVersion: agentcore/v1 + providers: + conversation: + kind: telnyx-relay + filler: + lookup_order: + say: "One moment" + delaySeconds: 1 + priority: high + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + agents: + items: + - { id: only, instructions: "hello" } + entries: + main: + agent: only + """; + + ConfigurationLoadException failure = Assert.Throws(() => Load(yaml)); + + Assert.Contains("priority", failure.Message, StringComparison.Ordinal); + } + + private static AgentCoreConfiguration Load(string yaml) + { + return ConfigurationLoader.LoadYaml(yaml); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs index 033b53c3..e2b1ab85 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/GuardedGraphEdgeTests.cs @@ -137,22 +137,31 @@ public async Task Rule16_TwentySixSimultaneousConversations_EachTakeTheirOwnEdge { using Harness harness = new(); CancellationToken token = TestContext.Current.CancellationToken; - using Barrier gate = new(FanOut); - IEnumerable> conversations = Enumerable.Range(0, FanOut).Select(index => Task.Run( - async () => + // An async rendezvous, not a Barrier: 26 real thread-pool threads parked in + // Barrier.SignalAndWait starved the pool under a small MinThreads setting. Every + // conversation here runs on whatever thread its continuations land on, and none of + // the 26 blocks a thread while it waits for the other 25 to arrive. + TaskCompletionSource released = new(TaskCreationOptions.RunContinuationsAsynchronously); + int arrived = 0; + + IEnumerable> conversations = Enumerable.Range(0, FanOut).Select(async index => + { + bool escalate = index % 2 == 0; + ConversationSession session = harness.NewSession(); + _ = session.State.TryWrite("escalate", escalate); + + // Nothing runs its turn until all 26 are ready, so the fan-out is really simultaneous. + if (Interlocked.Increment(ref arrived) == FanOut) { - bool escalate = index % 2 == 0; - ConversationSession session = harness.NewSession(); - _ = session.State.TryWrite("escalate", escalate); + released.SetResult(); + } - // Nothing starts until all 26 are ready, so the fan-out is really simultaneous. - gate.SignalAndWait(token); + await released.Task.WaitAsync(TimeSpan.FromSeconds(30), token).ConfigureAwait(false); - TurnResult turn = await session.RunTurnAsync($"conversation {index}", token).ConfigureAwait(false); - return (Escalate: escalate, turn.ReplyText); - }, - token)); + TurnResult turn = await session.RunTurnAsync($"conversation {index}", token).ConfigureAwait(false); + return (Escalate: escalate, turn.ReplyText); + }); (bool Escalate, string ReplyText)[] results = await Task.WhenAll(conversations); diff --git a/tests/AgentCore.Application.Tests/Configuration/Compilation/HostedWebSearchDropTests.cs b/tests/AgentCore.Application.Tests/Configuration/Compilation/HostedWebSearchDropTests.cs index 6f58e3ed..425ddf31 100644 --- a/tests/AgentCore.Application.Tests/Configuration/Compilation/HostedWebSearchDropTests.cs +++ b/tests/AgentCore.Application.Tests/Configuration/Compilation/HostedWebSearchDropTests.cs @@ -176,7 +176,7 @@ private static List ToolsFromHostSource(bool capable) AgentCompilationContext context = new(ScreeningChatClientFactory(Marker(capable))) { Tools = registry }; - return AgentToolCompiler.Build(item, model: null, declared, context, "/agents/items/0", static _ => null) ?? []; + return AgentToolCompiler.Build(item, model: null, declared, context, "/agents/items/0", static _ => null, []) ?? []; } @@ -202,7 +202,7 @@ private static List BuildAgentTools( AgentCompilationContext context = new(chatClients) { Tools = registry, Loggers = loggers }; - return AgentToolCompiler.Build(item, model, declaredById, context, "/agents/items/0", static _ => null) ?? []; + return AgentToolCompiler.Build(item, model, declaredById, context, "/agents/items/0", static _ => null, []) ?? []; } private static ToolRegistry BuildRegistry(IEnumerable sources, IReadOnlyList declared) diff --git a/tests/AgentCore.Application.Tests/Configuration/Validation/GraphNodeBackgroundCheckTests.cs b/tests/AgentCore.Application.Tests/Configuration/Validation/GraphNodeBackgroundCheckTests.cs new file mode 100644 index 00000000..7f7948a6 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Configuration/Validation/GraphNodeBackgroundCheckTests.cs @@ -0,0 +1,169 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Application.Tests.Configuration.Validation +{ + /// + /// Check 10 (ruling 6): an agent used as a graph node may not declare background:. MAF 1.21.0 + /// gives a workflow node's session no release hook, so a background child started inside a node would + /// outlive the conversation. + /// + public sealed class GraphNodeBackgroundCheckTests + { + [Fact] + public void ABackgroundAgentUsedAsAPatternGraphNode_FailsCheckTen() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, background: [ blocker ] } + - { id: blocker } + - { id: echo } + entries: + main: + graph: + pattern: sequential + agents: [ parent, echo ] + """; + + ConfigurationError error = Assert.Single(Evaluate(document, ConfigurationCheck.GraphNodeBackground)); + + Assert.Equal("/agents/items/0/background", error.Pointer); + Assert.Contains("the agent 'parent' declares background:", error.Message, StringComparison.Ordinal); + Assert.Contains("entry 'main'", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ABackgroundAgentUsedAsAnExplicitGraphNode_FailsCheckTen() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, background: [ blocker ] } + - { id: blocker } + - { id: echo } + entries: + main: + graph: + nodes: + - { id: start, agent: parent, start: true } + - { id: finish, agent: echo, output: true } + edges: + - { from: start, to: finish } + """; + + ConfigurationError error = Assert.Single(Evaluate(document, ConfigurationCheck.GraphNodeBackground)); + + Assert.Equal("/agents/items/0/background", error.Pointer); + Assert.Contains("the agent 'parent' declares background:", error.Message, StringComparison.Ordinal); + } + + /// + /// Claim (F8): check 10 must not live only in the validator. Before the fix, the public + /// built the document the validator above refuses. + /// + [Fact] + public void ABackgroundAgentUsedAsAPatternGraphNode_AlsoFailsCompileAll() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, background: [ blocker ] } + - { id: blocker } + - { id: echo } + entries: + main: + graph: + pattern: sequential + agents: [ parent, echo ] + """; + + _ = Assert.Throws(() => ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(document), + new AgentCompilationContext(new RoutingChatClientFactory(new ScriptedChatClient("x"))))); + } + + /// Claim (F8), the explicit-graph row. + [Fact] + public void ABackgroundAgentUsedAsAnExplicitGraphNode_AlsoFailsCompileAll() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, background: [ blocker ] } + - { id: blocker } + - { id: echo } + entries: + main: + graph: + nodes: + - { id: start, agent: parent, start: true } + - { id: finish, agent: echo, output: true } + edges: + - { from: start, to: finish } + """; + + _ = Assert.Throws(() => ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(document), + new AgentCompilationContext(new RoutingChatClientFactory(new ScriptedChatClient("x"))))); + } + + [Fact] + public void ABackgroundAgentUsedOutsideAnyGraph_PassesCheckTen() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, background: [ blocker ] } + - { id: blocker } + entries: + main: + agent: parent + """; + + Assert.DoesNotContain( + ConfigurationValidator.Evaluate(ConfigurationLoader.LoadYaml(document)).Errors, + error => error.Check == ConfigurationCheck.GraphNodeBackground); + } + + [Fact] + public void AGraphNodeAgentWithNoBackground_PassesCheckTen() + { + const string document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: first } + - { id: second } + entries: + main: + graph: + pattern: sequential + agents: [ first, second ] + """; + + Assert.DoesNotContain( + ConfigurationValidator.Evaluate(ConfigurationLoader.LoadYaml(document)).Errors, + error => error.Check == ConfigurationCheck.GraphNodeBackground); + } + + private static IReadOnlyList Evaluate(string yaml, ConfigurationCheck check) + { + AgentCoreConfiguration configuration = ConfigurationLoader.LoadYaml(yaml); + ConfigurationValidationResult result = ConfigurationValidator.Evaluate(configuration); + + Assert.All(result.Errors, error => Assert.Equal(check, error.Check)); + return result.Errors; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs index c0c4263c..6eed58d7 100644 --- a/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/InMemoryConversationStoreTests.cs @@ -4,7 +4,6 @@ using AgentCore.Application.Tests.Runtime; using Microsoft.Extensions.AI; using Xunit; -using System.Text.Json; namespace AgentCore.Application.Tests.Conversation { @@ -248,34 +247,32 @@ public async Task DeleteAsync_AConversation_TakesItsResumeStateWithIt() } [Fact] - public async Task DeleteAsync_AConversation_TakesItsSameIdContinuationWithIt() + public async Task DeleteAsync_AConversation_TakesItsResponseContinuationsWithIt() { - // Arrange — one id plays three roles now: the conversation, the conversation, and the continuation - // key. Deleting the conversation must forget the key or the next thread turn resumes the dead conversation. + // Arrange — a response id's row hangs off the conversation it continues; deleting the + // conversation must forget it too, or a later turn under a reused id could resolve a dead one. InMemoryConversationStore store = new(); _ = await store.CreateAsync("c1", Token); - using JsonDocument document = JsonDocument.Parse("""{ "conversationId": "c1" }"""); - await store.SaveContinuationAsync("c1", "c1", document.RootElement, Token); + await store.SaveContinuationAsync("resp_1", "c1", Token); // Act await store.DeleteAsync("c1", Token); // Assert - Assert.Null(await store.GetContinuationAsync("c1", Token)); + Assert.Null(await store.FindContinuationAsync("resp_1", Token)); } [Fact] - public async Task SweepAsync_AContinuationPastRetention_TakesTheRowOnly() + public async Task SweepAsync_AResponseIdPastRetention_TakesTheRowOnly() { - // Arrange — retention ages out resume state, not the conversation it resumes: the words and - // the slots the extractor filled stay, the same way a stale response id stops resolving with - // most providers while the conversation itself lives on. + // Arrange — retention ages out response ids, not the conversation they continue: the words and + // the slots the extractor filled stay, the same way a stale response id stops resolving while + // the conversation itself lives on. TestTimeProvider clock = new(); InMemoryConversationStore store = new(clock); _ = await store.CreateAsync("c1", Token); _ = await store.AppendAsync("c1", [Word()], new ConversationSessionState { Stage = "collecting" }, Token); - using JsonDocument document = JsonDocument.Parse("""{ "state": {} }"""); - await store.SaveContinuationAsync("c1", "c1", document.RootElement, Token); + await store.SaveContinuationAsync("resp_1", "c1", Token); // Act clock.Advance(TimeSpan.FromDays(2)); @@ -283,10 +280,23 @@ public async Task SweepAsync_AContinuationPastRetention_TakesTheRowOnly() // Assert Assert.Equal(1, swept); - Assert.Null(await store.GetContinuationAsync("c1", Token)); + Assert.Null(await store.FindContinuationAsync("resp_1", Token)); Assert.NotNull(await store.GetAsync("c1", Token)); } + [Fact] + public async Task FindContinuationAsync_AConversationIdWithNoResponseRow_IsNull() + { + // Arrange — D3: a conversation id is never itself a response_continuation row, so resolving one + // to "its own" continuation finds nothing; a caller falls back to GetAsync for that. + InMemoryConversationStore store = new(); + _ = await store.CreateAsync("c1", Token); + await store.SaveContinuationAsync("resp_1", "c1", Token); + + // Act & Assert + Assert.Null(await store.FindContinuationAsync("c1", Token)); + } + private static ConversationMessageDraft Word() { return new(0, new ChatMessage(ChatRole.User, "hello"), "m0"); diff --git a/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreAppendTests.cs b/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreAppendTests.cs index 8319e25b..852ae771 100644 --- a/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreAppendTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreAppendTests.cs @@ -143,6 +143,25 @@ public async Task RewriteAsync_OneRow_ChangesOnlyThatRowsContent() Assert.Equal(["corrected", "second"], rows.Select(row => row.Content.Text)); } + [Fact] + public async Task DeleteMessageAsync_OneRow_RemovesOnlyThatRow_AndTheOthersKeepTheirOrdinals() + { + // Arrange + IConversationStore store = new InMemoryConversationStore(); + _ = await store.CreateAsync("c1", Token); + _ = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "zero") { MessageId = "m0" }, Token); + _ = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "one") { MessageId = "m1" }, Token); + _ = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "two") { MessageId = "m2" }, Token); + + // Act + await store.DeleteMessageAsync("c1", "m1", Token); + await store.DeleteMessageAsync("c1", "never-written", Token); + + // Assert + IReadOnlyList rows = await store.ReadAllAsync("c1", Token); + Assert.Equal([(0, "zero"), (2, "two")], rows.Select(row => (row.Ordinal, row.Content.Text))); + } + [Fact] public async Task AppendAsync_AfterATruncate_NeverReusesTheOrdinalsItTook() { diff --git a/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs b/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs new file mode 100644 index 00000000..c6f0ba3f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Conversation/Memory/InMemoryConversationStoreTurnConflictTests.cs @@ -0,0 +1,137 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Conversation.Memory +{ + /// + /// The store refuses a turn the conversation already saved (owner ruling 2026-09-24, "refuse at save"): two + /// sessions that resumed the same conversation both run its next turn, and only the first commit is kept. + /// +#pragma warning disable CA1859 // store is typed as IConversationStore throughout, as every caller holds it. + public sealed class InMemoryConversationStoreTurnConflictTests + { + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task AppendAsync_ATurnAlreadySaved_IsRefused_AndWritesNothing() + { + // Arrange + IConversationStore store = new InMemoryConversationStore(); + _ = await store.CreateAsync("c1", Token); + _ = await CommitAsync(store, "c1", 0, "opener"); + _ = await CommitAsync(store, "c1", 1, "first"); + + // Act + Exception? refused = await Record.ExceptionAsync(() => CommitAsync(store, "c1", 1, "second")); + + // Assert + Assert.IsType(refused); + IReadOnlyList rows = await store.ReadForSessionAsync("c1", Token); + Assert.Equal(["opener-user", "opener-reply", "first-user", "first-reply"], rows.Select(row => row.MessageId)); + ConversationRecord? record = await store.GetAsync("c1", Token); + Assert.Equal(("first", 2, 4), (record?.State?.Stage, record?.State?.NextTurnIndex, record?.NextOrdinal)); + } + + [Fact] + public async Task AppendAsync_TheNextTurnAfterARefusal_AndAnAppendThatNamesNoTurn_BothLand() + { + // Arrange + IConversationStore store = new InMemoryConversationStore(); + _ = await store.CreateAsync("c1", Token); + _ = await CommitAsync(store, "c1", 0, "opener"); + _ = await Record.ExceptionAsync(() => CommitAsync(store, "c1", 0, "late")); + + // Act + ConversationMessage outside = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "outside"), Token); + IReadOnlyList next = await CommitAsync(store, "c1", 1, "next"); + + // Assert + Assert.Equal((2, 1), (outside.Ordinal, outside.TurnIndex)); + Assert.Equal([(3, 1), (4, 1)], next.Select(row => (row.Ordinal, row.TurnIndex))); + } + + [Fact] + public async Task AppendAsync_ManyCommitsOfOneTurnAtOnce_KeepsExactlyOne() + { + IConversationStore store = new InMemoryConversationStore(); + + for (int iteration = 0; iteration < 50; iteration++) + { + // Arrange + string conversationId = "race-" + iteration; + int parties = 2 + (iteration % 7); + _ = await store.CreateAsync(conversationId, Token); + _ = await CommitAsync(store, conversationId, 0, "opener"); + using Barrier start = new(parties); + + // Act + Task[] commits = [.. Enumerable.Range(0, parties).Select(party => Task.Run( + async () => + { + start.SignalAndWait(Token); + try + { + _ = await CommitAsync(store, conversationId, 1, "writer" + party); + return true; + } + catch (ConversationTurnConflictException) + { + return false; + } + }, + Token))]; + bool[] kept = await Task.WhenAll(commits); + + // Assert + int winner = Assert.Single(Enumerable.Range(0, parties), party => kept[party]); + IReadOnlyList rows = await store.ReadForSessionAsync(conversationId, Token); + Assert.Equal( + ["opener-user", "opener-reply", $"writer{winner}-user", $"writer{winner}-reply"], + rows.Select(row => row.MessageId)); + ConversationRecord? record = await store.GetAsync(conversationId, Token); + Assert.Equal("writer" + winner, record?.State?.Stage); + } + } + + [Fact] + public async Task AppendAsync_AStateOlderThanTheStoredOne_WritesTheWordsButNotTheState() + { + // Arrange + IConversationStore store = new InMemoryConversationStore(); + _ = await store.CreateAsync("c1", Token); + _ = await CommitAsync(store, "c1", 0, "opener"); + _ = await CommitAsync(store, "c1", 1, "first"); + + // Act + IReadOnlyList written = await store.AppendAsync( + "c1", + [new ConversationMessageDraft(TurnIndex: null, new ChatMessage(ChatRole.User, "outside"), "outside")], + new ConversationSessionState { NextTurnIndex = 1, Stage = "stale" }, + Token); + + // Assert + Assert.Equal("outside", Assert.Single(written).MessageId); + ConversationRecord? record = await store.GetAsync("c1", Token); + Assert.Equal(("first", 2), (record?.State?.Stage, record?.State?.NextTurnIndex)); + } + + /// Commits one turn as a session does: the user's words and the reply, with the state after them. + private static async Task> CommitAsync( + IConversationStore store, string conversationId, int turnIndex, string label) + { + return await store.AppendAsync( + conversationId, + [ + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.User, label), label + "-user"), + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Assistant, "re " + label), label + "-reply"), + ], + new ConversationSessionState { NextTurnIndex = turnIndex + 1, Stage = label }, + Token); + } + } +#pragma warning restore CA1859 +} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/AgentCoreTelemetryBucketsTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/AgentCoreTelemetryBucketsTests.cs new file mode 100644 index 00000000..bf102b1d --- /dev/null +++ b/tests/AgentCore.Application.Tests/Diagnostics/AgentCoreTelemetryBucketsTests.cs @@ -0,0 +1,67 @@ +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using AgentCore.Application.Diagnostics; +using Xunit; + +namespace AgentCore.Application.Tests.Diagnostics +{ + /// + /// Every agentcore.* histogram carries unit s, so the .NET SDK's default millisecond-shaped bucket + /// bounds [0,5,10,...,10000] would put every reading in the first bucket or two and make a percentile + /// query meaningless (audit finding F1, docs/probes/r2/audit/AUDIT.md). Each histogram gives bounds in seconds + /// as InstrumentAdvice<double> on CreateHistogram; is how + /// a listener -- and the OTel SDK itself -- reads them back without needing a full exporter (confirmed against + /// OpenTelemetry 1.17.0 in a scratch probe: the exported explicitBounds equal the advice given here). + /// + public sealed class AgentCoreTelemetryBucketsTests + { + private static readonly double[] OperationDurationBounds = + [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92]; + + private static readonly double[] TimeToFirstTokenBounds = + [0.001, 0.005, 0.01, 0.02, 0.04, 0.06, 0.08, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0]; + + /// Each histogram of this library, beside the bounds its own kind of reading takes. + public static TheoryData Histograms => + new() + { + { "agentcore.turn.duration", OperationDurationBounds }, + { "agentcore.turn.time_to_reply_end", OperationDurationBounds }, + { "agentcore.turn.time_to_first_token", TimeToFirstTokenBounds }, + { "agentcore.turn.time_to_first_speech", TimeToFirstTokenBounds }, + { "agentcore.barge_in.latency", TimeToFirstTokenBounds }, + }; + + [Theory] + [MemberData(nameof(Histograms))] + public void AHistogram_AdvisesTheSecondsBoundsOfItsGenAiCounterpart(string name, double[] expectedBounds) + { + double[]? bounds = null; + using MeterListener listener = new() + { + InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal) + && string.Equals(instrument.Name, name, StringComparison.Ordinal) + && instrument is Histogram histogram) + { + bounds = histogram.Advice?.HistogramBucketBoundaries?.ToArray(); + } + + // A published instrument is never measured here: reading Advice is enough, and firing a + // real measurement would touch every other test's process-wide meter for nothing. + }, + }; + + listener.Start(); + + // MeterName is a const, inlined at every call site, so referencing it above never runs + // AgentCoreTelemetry's static field initializers. This forces them, the same way any earlier + // turn in the process would: the instruments are process-wide singletons and only publish once. + RuntimeHelpers.RunClassConstructor(typeof(AgentCoreTelemetry).TypeHandle); + + Assert.NotNull(bounds); + Assert.Equal(expectedBounds, bounds); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs index d9e61d89..30ad68fe 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/LibraryOpenTelemetryTests.cs @@ -284,7 +284,7 @@ public async Task ASingleAgentTurn_CarriesTheConversationIdOnTheTurnSpanAndOnNei /// One turn makes two model calls, and both of them emit a chat span. /// /// - /// ConversationSessionTests.ATurn_MakesTwoModelCalls_TheReplyAndTheExtractor proves the two conversations + /// ConversationSessionTranscriptTests.ATurn_MakesTwoModelCalls_TheReplyAndTheExtractor proves the two conversations /// happen. This proves both are instrumented. Until ConversationSessionFactory.CreateExtractor /// wrapped the client it resolves, only the reply had a span: the extractor's duration, its token /// usage, and its failures reached no exporter at all, so a two-turn conversation reported half the model diff --git a/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs index 153a6ce2..465507af 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/LoggingConversationObserverTests.cs @@ -11,9 +11,10 @@ namespace AgentCore.Application.Tests.Diagnostics /// /// /// Moving the conversation sites behind the hook must change neither the text an operator greps for nor how - /// often it appears, so these tests pin the event id, the level, and the fields of each of the six - /// lines. They also pin the six kinds that write nothing here: a normal conversation is recorded by the chain - /// of D23, and a log is not. + /// often it appears, so these tests pin the event id, the level, and the fields of each of the five + /// lines this observer writes. They also pin the kinds that write nothing here: a normal conversation is + /// recorded by the chain of D23 and a log is not, and a fact whose exception is logged at its own catch + /// site (row six, and a store 1 write or read that failed) is not logged twice. /// public sealed class LoggingConversationObserverTests { @@ -21,7 +22,6 @@ public sealed class LoggingConversationObserverTests /// The ids the source-generated methods carry. They are unchanged by the hook. private const int ExtractionFailedEventId = 1; - private const int ToolBudgetSpentEventId = 2; private const int EmptyReplyEventId = 3; private const int PromptRefusedEventId = 6; private const int ModerationUnavailableEventId = 7; @@ -35,31 +35,16 @@ public sealed class LoggingConversationObserverTests ConversationEventKind.ReplyInterrupted, ConversationEventKind.ConversationEnded, - // Logged, but not here: the line is written where the write was refused, which is the only - // place the exception still exists. A line from this observer too would double it. + // Logged, but not here: the line is written where the exception still exists (ConversationGate, + // ConversationTranscriptLedger, ConversationTurnCommit), with the object itself and not only its + // message. A line from this observer too would double it. ConversationEventKind.TranscriptWriteFailed, + ConversationEventKind.ToolFailed, // The quietest of them all: counted, and not logged anywhere. ConversationEventKind.ModerationClean, ]; - [Fact] - public async Task ASpentToolBudget_IsAnErrorNamingTheTurnAndTheFault() - { - RecordingLogger logger = new(); - - await Observe( - logger, - Event(ConversationEventKind.ToolFailed, turnIndex: 2, AuditPayloadKeys.ToolError, "the CRM refused.")); - - LogLine line = Assert.Single(logger.Of(ToolBudgetSpentEventId)); - Assert.Equal(LogLevel.Error, line.Level); - Assert.Equal( - "A tool of conversation conversation-1 failed four times in turn 2: the CRM refused. " - + "The turn spoke the fallback and the conversation continues.", - line.Message); - } - [Fact] public async Task AQuietRun_IsAWarningNamingTheTurn() { diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs index 125dcc7f..e4906750 100644 --- a/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs +++ b/tests/AgentCore.Application.Tests/Diagnostics/TelemetryConversationObserverTests.cs @@ -1,6 +1,8 @@ +using System.Collections.ObjectModel; using System.Diagnostics.Metrics; using AgentCore.Application.Diagnostics; using AgentCore.Application.Runtime; +using AgentCore.Domain.Audit; using Xunit; namespace AgentCore.Application.Tests.Diagnostics @@ -8,20 +10,6 @@ namespace AgentCore.Application.Tests.Diagnostics /// /// The three counters of section 8.6, incremented from a fact instead of from the turn loop. /// - /// - /// - /// Nothing about the numbers changed when the conversation sites moved behind the hook, so these tests pin the - /// instrument, the attribute key, and the value each kind writes. T61 is why the values are closed and - /// why no conversation id appears among them: a cumulative series lives forever once it is created, and the - /// Grafana Cloud free tier binds at 10,000. - /// - /// - /// The meter is a process-wide singleton, so another test running beside this one measures on the same - /// instruments. Every assertion here is therefore about what the observer DID write, and the negative - /// side — which kinds are counted as audit events at all — is pinned on the mapping itself, in - /// ConversationEventKindsTests, where nothing else can add to the reading. - /// - /// public sealed class TelemetryConversationObserverTests { private const string FailureInstrument = "agentcore.turn.failures"; @@ -39,6 +27,7 @@ public sealed class TelemetryConversationObserverTests { ConversationEventKind.ToolFailed, "tool" }, { ConversationEventKind.EmptyReply, "empty_reply" }, { ConversationEventKind.ExtractionFailed, "extraction" }, + { ConversationEventKind.RunFaulted, "run" }, }; /// Each moderation verdict, beside the value an operator alerts on. @@ -96,10 +85,29 @@ public async Task NoEvent_IsRefused() await new TelemetryConversationObserver().OnConversationEventAsync(null!, TestContext.Current.CancellationToken)); } + // A turn whose tool fails four times raises four per-call facts, each with a tool call id, and one + // turn-level fact with none. agentcore.turn.failures promises one count per failed turn, so only the + // turn-level fact moves it (audit finding E5, docs/probes/r2/audit/AUDIT.md). + [Fact] + public async Task APerCallToolFailure_MovesNothing_OnlyTheTurnLevelFactDoes() + { + List perCall = await MeasureAsync( + ConversationEventKind.ToolFailed, + new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.ToolCallId] = "call-1" }); + + Assert.DoesNotContain(new Reading(FailureInstrument, FailureKey, "tool"), perCall); + + List turnLevel = await MeasureAsync(ConversationEventKind.ToolFailed); + + Assert.Contains(new Reading(FailureInstrument, FailureKey, "tool"), turnLevel); + } + /// Hands one fact to the observer and reads back what the meter saw. /// What happened. + /// The fact's payload, or empty for a kind that carries none in this suite. /// Every attribute of every measurement, as instrument, key, and value. - private static async Task> MeasureAsync(ConversationEventKind kind) + private static async Task> MeasureAsync( + ConversationEventKind kind, IReadOnlyDictionary? payload = null) { List readings = []; using MeterListener listener = new(); @@ -132,6 +140,7 @@ private static async Task> MeasureAsync(ConversationEventKind kind OccurredAt = DateTimeOffset.UnixEpoch, EventId = Guid.CreateVersion7(), TurnIndex = 0, + Payload = payload ?? ReadOnlyDictionary.Empty, }, TestContext.Current.CancellationToken); diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs new file mode 100644 index 00000000..7df926fe --- /dev/null +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnLogOnceTests.cs @@ -0,0 +1,123 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using Microsoft.Extensions.Logging; +using Xunit; +using AgentCore.Domain; +using static AgentCore.Application.Tests.Diagnostics.TurnObservabilityHarness; + +namespace AgentCore.Application.Tests.Diagnostics +{ + /// + /// The rows of section 8.7 that say "log once": a failed extraction, an empty reply, the fourth tool failure, and a + /// guard that throws. + /// + public sealed class TurnLogOnceTests + { + [Fact] + public async Task AFailedExtraction_IsLoggedOnceForTheTurnAndTheConversationContinues() + { + RecordingLogger logger = new(); + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new("I am sorry, I cannot do that."); + ConversationSession session = Build(PolicyYaml, reply, fill, logger: logger).Create("conversation-x"); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // Row two of section 8.7: leave the slots unchanged, log once for the turn, and continue. + LogLine line = Assert.Single(logger.Of(1)); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.Contains("conversation-x", line.Message, StringComparison.Ordinal); + Assert.NotNull(turn.ExtractionFailure); + Assert.Equal("hello there.", turn.ReplyText); + } + + [Fact] + public async Task AnEmptyReply_IsLoggedOnce() + { + RecordingLogger logger = new(); + using SequencedChatClient reply = new(" "); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill, logger: logger).Create("conversation-x"); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + LogLine line = Assert.Single(logger.Of(3)); + Assert.Equal(LogLevel.Warning, line.Level); + + // The empty reply is not a tool fault, so the tool row stays silent. + Assert.Empty(logger.Of(2)); + } + + [Fact] + public async Task TheFourthConsecutiveToolFailure_IsLoggedOnce() + { + RecordingLogger logger = new(); + using LoopingToolCallingChatClient reply = new(); + ConversationSession session = Build(ToolYaml, reply, null, new ThrowingToolBuilder().Create, logger: logger).Create("conversation-x"); + + _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + // Row six of section 8.7. The turn spoke the fallback, and the conversation is still alive. + // + // ONE line, and the turn spent four tool calls to get here. Each of those four is a row in + // the chain of D23, which is where a record of a conversation belongs; the log gets the turn-level + // fact alone. + LogLine line = Assert.Single(logger.Of(2)); + Assert.Equal(LogLevel.Error, line.Level); + + // The message names the turn and never the fault: the fault's message and stack trace ride + // the Exception object instead (owner ruling: type in spans, full error in logs), so a span + // never has to repeat what the log already owns. + Assert.DoesNotContain(ThrowingToolBuilder.Message, line.Message, StringComparison.Ordinal); + Assert.NotNull(line.Exception); + Assert.Contains(ThrowingToolBuilder.Message, line.Exception!.Message, StringComparison.Ordinal); + Assert.False(session.IsComplete); + + // The tool row and the empty-reply row are different failures, and only one of them fired. + Assert.Empty(logger.Of(3)); + } + + [Fact] + public async Task ASessionWithNoLogger_RunsATurnAndThrowsNothing() + { + using SequencedChatClient reply = new(" "); + using SequencedChatClient fill = new("I am sorry, I cannot do that."); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + // Two of the three "log once" rows fire in this one turn, and neither has anywhere to write. + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); + Assert.NotNull(turn.ExtractionFailure); + } + + [Fact] + public void AGuardThatDoesNotParse_IsLoggedOnceUnderItsName() + { + RecordingLogger logger = new(); + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml( + """ + apiVersion: agentcore/v1 + guards: + impossible: { "no_such_operator": [ 1, 2 ] } + agents: + items: + - { id: only, instructions: "I answer everything" } + entries: + main: + agent: only + """); + + _ = new GuardEvaluator(document.Guards, logger); + + LogLine line = Assert.Single(logger.Of(4)); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.Contains("impossible", line.Message, StringComparison.Ordinal); + Assert.NotNull(line.Exception); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs new file mode 100644 index 00000000..8afa88d7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnMetricsTests.cs @@ -0,0 +1,140 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using System.Diagnostics.Metrics; +using Xunit; +using static AgentCore.Application.Tests.Diagnostics.TurnObservabilityHarness; + +namespace AgentCore.Application.Tests.Diagnostics +{ + /// + /// The metrics a turn reports, and the attributes they may carry. See T61. + /// + public sealed class TurnMetricsTests + { + /// The only attribute keys any metric of this library may carry. See T61. + private static readonly string[] PermittedMetricKeys = + [ + "agentcore.turn.outcome", + "agentcore.failure.kind", + "agentcore.audit.kind", + + // Three values: clean, flagged, unavailable. Three series for each replica, against the + // 10,000 ceiling of item 12. No conversation id rides on it, so T61 holds. + "agentcore.moderation.outcome", + ]; + + [Fact] + public async Task NoMetricAttribute_EverCarriesTheConversationId() + { + // A value nothing else in the process can produce, so a hit is this conversation and not another. + string conversationId = "conversation-" + Guid.NewGuid().ToString("N"); + + List> tags = await MeasureAsync(conversationId); + + // The .NET default is cumulative temporality, so one conversation id on a metric attribute is one + // permanent series. A day of conversations would then be a day of permanent series, and the free + // tier binds at 10,000. See D26 and T61. + Assert.DoesNotContain(tags, tag => string.Equals(tag.Value as string, conversationId, StringComparison.Ordinal)); + } + + [Fact] + public async Task EveryMetricAttribute_ComesFromAClosedSet() + { + List> tags = await MeasureAsync("conversation-" + Guid.NewGuid().ToString("N")); + + // The listener really saw the instruments of this library. + Assert.NotEmpty(tags); + + // T61 again, and this is the half that catches the next attribute somebody adds. Every key + // below takes a handful of values the library writes itself. A key that is not on this list + // has not been costed, so the build refuses it. + Assert.All(tags, tag => Assert.Contains(tag.Key, PermittedMetricKeys, StringComparer.Ordinal)); + } + + [Fact] + public async Task ATurn_RecordsItsDurationOnceWithTheOutcome() + { + List> durations = []; + using MeterListener listener = new(); + listener.InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal) + && string.Equals(instrument.Name, "agentcore.turn.duration", StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((_, value, tags, _) => + { + lock (durations) + { + durations.Add(new Measurement(value, tags)); + } + }); + listener.Start(); + + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + TestTimeProvider clock = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + listener.Dispose(); + + // Other tests may run beside this one, so the assertion reads what this turn produced rather + // than the whole list. + Assert.Contains(durations, sample => sample.Tags.ToArray().Any(tag => + string.Equals(tag.Key, "agentcore.turn.outcome", StringComparison.Ordinal) + && string.Equals(tag.Value as string, "completed", StringComparison.Ordinal))); + } + + /// Runs three turns of one conversation and collects every metric attribute the library wrote. + /// The id of the conversation. + /// Every attribute of every measurement, flattened. + private static async Task>> MeasureAsync(string conversationId) + { + List> tags = []; + using MeterListener listener = new(); + + listener.InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(instrument); + } + }; + + listener.SetMeasurementEventCallback((_, _, measured, _) => Collect(tags, measured)); + listener.SetMeasurementEventCallback((_, _, measured, _) => Collect(tags, measured)); + listener.Start(); + + using SequencedChatClient reply = new("hello there.", " ", "goodbye."); + using SequencedChatClient fill = new(StayingNull, StayingNull, /*lang=json,strict*/ """{ "callerSaidGoodbye": true }"""); + ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: new InMemoryAuditSink()) + .Create(conversationId); + + CancellationToken token = TestContext.Current.CancellationToken; + _ = await session.RunTurnAsync("hi", token); + _ = await session.RunTurnAsync("still there?", token); + _ = await session.RunTurnAsync("goodbye", token); + + listener.Dispose(); + return tags; + } + + /// Adds the attributes of one measurement to the list. + private static void Collect( + List> tags, + ReadOnlySpan> measured) + { + KeyValuePair[] copy = measured.ToArray(); + lock (tags) + { + tags.AddRange(copy); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs new file mode 100644 index 00000000..2382bf43 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityHarness.cs @@ -0,0 +1,133 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using Xunit; + +namespace AgentCore.Application.Tests.Diagnostics +{ + /// + /// The configurations, the session builder, and the span listener that the tests of what a turn reports share. + /// + internal static class TurnObservabilityHarness + { + internal const string PolicyYaml = + """ + apiVersion: agentcore/v1 + state: + callerSaidGoodbye: { type: boolean, default: false, writer: extractor } + guards: + saidGoodbye: { var: callerSaidGoodbye } + extractor: + model: { ref: fill } + when: after_reply + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + - { id: closer, instructions: "close the conversation" } + entries: + main: + policy: + initial: greeting + stages: + - { id: greeting, agent: greeter, to: [ { stage: close, when: saidGoodbye } ] } + - { id: close, agent: closer, terminal: true } + """; + + internal const string ToolYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } + agents: + defaults: + model: { ref: reply } + items: + - { id: only, instructions: "I answer everything", tools: [ lookup_order ] } + entries: + main: + agent: only + """; + + internal const string StayingNull = /*lang=json,strict*/ """{ "callerSaidGoodbye": null }"""; + + /// Copies what the listener has collected so far. + /// The list the listener writes into. + /// The copy, which no other thread can change. + internal static List Snapshot(List spans) + { + lock (spans) + { + return [.. spans]; + } + } + + /// Subscribes to the one activity source of this library. + internal static ActivityListener ListenTo(List spans) + { + ActivityListener listener = new() + { + ShouldListenTo = source => + string.Equals(source.Name, AgentCoreTelemetry.ActivitySourceName, StringComparison.Ordinal), + Sample = (ref _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + lock (spans) + { + spans.Add(activity); + } + }, + }; + + ActivitySource.AddActivityListener(listener); + return listener; + } + + internal static ConversationSessionFactory Build( + string yaml, + IChatClient reply, + IChatClient? fill, + Func? tools = null, + TimeProvider? timeProvider = null, + IAuditSinkPort? auditSink = null, + ILogger? logger = null) + { + // ConversationObservers.Standard requires a sink. A test that does not read its events gets a fresh + // in-memory one. + IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); + + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + RoutingChatClientFactory chatClients = new(reply); + if (fill is not null) + { + _ = chatClients.Route("fill", fill); + } + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) + { + Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), + })["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, chatClients), + timeProvider, + logger, + ConversationObservers.Standard(sink, logger)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityTests.cs deleted file mode 100644 index 2bb42318..00000000 --- a/tests/AgentCore.Application.Tests/Diagnostics/TurnObservabilityTests.cs +++ /dev/null @@ -1,466 +0,0 @@ -using AgentCore.TestSupport; -using AgentCore.Application.Audit.Memory; -using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Diagnostics; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Application.Tests.Runtime; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using System.Diagnostics.Metrics; -using System.Diagnostics; -using Xunit; -using AgentCore.Domain; - -namespace AgentCore.Application.Tests.Diagnostics -{ - /// - /// What a turn reports: one span, three metrics, and the three "log once" rows of section 8.7. - /// - /// - /// Every test here runs offline. There is no collector, no exporter, and no network conversation. - /// - public sealed class TurnObservabilityTests - { - /// The only attribute keys any metric of this library may carry. See T61. - private static readonly string[] PermittedMetricKeys = - [ - "agentcore.turn.outcome", - "agentcore.failure.kind", - "agentcore.audit.kind", - - // Three values: clean, flagged, unavailable. Three series for each replica, against the - // 10,000 ceiling of item 12. No conversation id rides on it, so T61 holds. - "agentcore.moderation.outcome", - ]; - - private const string PolicyYaml = - """ - apiVersion: agentcore/v1 - state: - callerSaidGoodbye: { type: boolean, default: false, writer: extractor } - guards: - saidGoodbye: { var: callerSaidGoodbye } - extractor: - model: { ref: fill } - when: after_reply - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "greet the caller" } - - { id: closer, instructions: "close the conversation" } - entries: - main: - policy: - initial: greeting - stages: - - { id: greeting, agent: greeter, to: [ { stage: close, when: saidGoodbye } ] } - - { id: close, agent: closer, terminal: true } - """; - - private const string ToolYaml = - """ - apiVersion: agentcore/v1 - tools: - - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } - agents: - defaults: - model: { ref: reply } - items: - - { id: only, instructions: "I answer everything", tools: [ lookup_order ] } - entries: - main: - agent: only - """; - - private const string StayingNull = /*lang=json,strict*/ """{ "callerSaidGoodbye": null }"""; - - // ------------------------------------------------------------------------------------------- - // T61: the metric attributes, and what may never appear on one. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task NoMetricAttribute_EverCarriesTheConversationId() - { - // A value nothing else in the process can produce, so a hit is this conversation and not another. - string conversationId = "conversation-" + Guid.NewGuid().ToString("N"); - - List> tags = await MeasureAsync(conversationId); - - // The .NET default is cumulative temporality, so one conversation id on a metric attribute is one - // permanent series. A day of conversations would then be a day of permanent series, and the free - // tier binds at 10,000. See D26 and T61. - Assert.DoesNotContain(tags, tag => string.Equals(tag.Value as string, conversationId, StringComparison.Ordinal)); - } - - [Fact] - public async Task EveryMetricAttribute_ComesFromAClosedSet() - { - List> tags = await MeasureAsync("conversation-" + Guid.NewGuid().ToString("N")); - - // The listener really saw the instruments of this library. - Assert.NotEmpty(tags); - - // T61 again, and this is the half that catches the next attribute somebody adds. Every key - // below takes a handful of values the library writes itself. A key that is not on this list - // has not been costed, so the build refuses it. - Assert.All(tags, tag => Assert.Contains(tag.Key, PermittedMetricKeys, StringComparer.Ordinal)); - } - - [Fact] - public async Task ATurn_RecordsItsDurationOnceWithTheOutcome() - { - List> durations = []; - using MeterListener listener = new(); - listener.InstrumentPublished = (instrument, active) => - { - if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal) - && string.Equals(instrument.Name, "agentcore.turn.duration", StringComparison.Ordinal)) - { - active.EnableMeasurementEvents(instrument); - } - }; - listener.SetMeasurementEventCallback((_, value, tags, _) => - { - lock (durations) - { - durations.Add(new Measurement(value, tags)); - } - }); - listener.Start(); - - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - TestTimeProvider clock = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - listener.Dispose(); - - // Other tests may run beside this one, so the assertion reads what this turn produced rather - // than the whole list. - Assert.Contains(durations, sample => sample.Tags.ToArray().Any(tag => - string.Equals(tag.Key, "agentcore.turn.outcome", StringComparison.Ordinal) - && string.Equals(tag.Value as string, "completed", StringComparison.Ordinal))); - } - - // ------------------------------------------------------------------------------------------- - // The span. A high-cardinality value belongs here and nowhere else. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task ATurn_IsOneSpanThatCarriesTheConversationIdAsAGenAiAttribute() - { - string conversationId = "conversation-" + Guid.NewGuid().ToString("N"); - List spans = await RecordSpansAsync(conversationId); - - Activity span = Assert.Single(spans); - - Assert.Equal(AgentCoreTelemetry.TurnActivityName, span.OperationName); - - // Item 7 asks for gen_ai.* attributes on the trace, and gen_ai.conversation.id is the - // convention's own name for the conversation a request belongs to. A conversation is that - // conversation, and a span attribute costs no series. - Assert.Equal(conversationId, span.GetTagItem("gen_ai.conversation.id")); - Assert.Equal(0, span.GetTagItem("agentcore.turn.index")); - Assert.Equal("greeting", span.GetTagItem("agentcore.stage.before")); - Assert.Equal("greeting", span.GetTagItem("agentcore.stage.after")); - Assert.Equal("completed", span.GetTagItem("agentcore.turn.outcome")); - Assert.Equal(ActivityStatusCode.Unset, span.Status); - } - - [Fact] - public async Task AFailedTurn_MarksItsSpanWithTheReasonOfSectionEightSeven() - { - List spans = []; - using ActivityListener listener = ListenTo(spans); - - using SequencedChatClient reply = new(" "); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-" + Guid.NewGuid().ToString("N")); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Activity span = Assert.Single(Snapshot(spans), item => string.Equals( - item.GetTagItem("gen_ai.conversation.id") as string, session.ConversationId, StringComparison.Ordinal)); - - Assert.Equal("failed", span.GetTagItem("agentcore.turn.outcome")); - Assert.Equal(ActivityStatusCode.Error, span.Status); - Assert.Equal(ConversationSession.EmptyReplyReason, span.StatusDescription); - } - - [Fact] - public async Task AStreamedTurn_IsOneSpanToo() - { - List spans = []; - using ActivityListener listener = ListenTo(spans); - - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-" + Guid.NewGuid().ToString("N")); - - await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) - { - Assert.NotNull(update); - } - - // The span travels on the turn record and not on Activity.Current, because an async iterator - // restores the execution context of its caller at every yield. - Activity span = Assert.Single(Snapshot(spans), item => string.Equals( - item.GetTagItem("gen_ai.conversation.id") as string, session.ConversationId, StringComparison.Ordinal)); - - Assert.Equal("completed", span.GetTagItem("agentcore.turn.outcome")); - } - - // ------------------------------------------------------------------------------------------- - // Section 8.7: three rows say "log once". - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task AFailedExtraction_IsLoggedOnceForTheTurnAndTheConversationContinues() - { - RecordingLogger logger = new(); - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new("I am sorry, I cannot do that."); - ConversationSession session = Build(PolicyYaml, reply, fill, logger: logger).Create("conversation-x"); - - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // Row two of section 8.7: leave the slots unchanged, log once for the turn, and continue. - LogLine line = Assert.Single(logger.Of(1)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Contains("conversation-x", line.Message, StringComparison.Ordinal); - Assert.NotNull(turn.ExtractionFailure); - Assert.Equal("hello there.", turn.ReplyText); - } - - [Fact] - public async Task AnEmptyReply_IsLoggedOnce() - { - RecordingLogger logger = new(); - using SequencedChatClient reply = new(" "); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill, logger: logger).Create("conversation-x"); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - LogLine line = Assert.Single(logger.Of(3)); - Assert.Equal(LogLevel.Warning, line.Level); - - // The empty reply is not a tool fault, so the tool row stays silent. - Assert.Empty(logger.Of(2)); - } - - [Fact] - public async Task TheFourthConsecutiveToolFailure_IsLoggedOnce() - { - RecordingLogger logger = new(); - using LoopingToolCallingChatClient reply = new(); - ConversationSession session = Build(ToolYaml, reply, null, new ThrowingToolBuilder().Create, logger: logger).Create("conversation-x"); - - _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - // Row six of section 8.7. The turn spoke the fallback, and the conversation is still alive. - // - // ONE line, and the turn spent four tool calls to get here. Each of those four is a row in - // the chain of D23, which is where a record of a conversation belongs; the log gets the turn-level - // fact alone, so the volume an operator pays Grafana Cloud for did not move. - LogLine line = Assert.Single(logger.Of(2)); - Assert.Equal(LogLevel.Error, line.Level); - Assert.Contains(ThrowingToolBuilder.Message, line.Message, StringComparison.Ordinal); - Assert.False(session.IsComplete); - - // The tool row and the empty-reply row are different failures, and only one of them fired. - Assert.Empty(logger.Of(3)); - } - - [Fact] - public async Task ASessionWithNoLogger_RunsATurnAndThrowsNothing() - { - using SequencedChatClient reply = new(" "); - using SequencedChatClient fill = new("I am sorry, I cannot do that."); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - // Two of the three "log once" rows fire in this one turn, and neither has anywhere to write. - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.NotNull(turn.ExtractionFailure); - } - - // ------------------------------------------------------------------------------------------- - // Section 8.7, row five: a guard that throws is logged once for each guard. - // ------------------------------------------------------------------------------------------- - [Fact] - public void AGuardThatDoesNotParse_IsLoggedOnceUnderItsName() - { - RecordingLogger logger = new(); - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml( - """ - apiVersion: agentcore/v1 - guards: - impossible: { "no_such_operator": [ 1, 2 ] } - agents: - items: - - { id: only, instructions: "I answer everything" } - entries: - main: - agent: only - """); - - _ = new GuardEvaluator(document.Guards, logger); - - LogLine line = Assert.Single(logger.Of(4)); - Assert.Equal(LogLevel.Warning, line.Level); - Assert.Contains("impossible", line.Message, StringComparison.Ordinal); - Assert.NotNull(line.Exception); - } - - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- - /// Runs three turns of one conversation and collects every metric attribute the library wrote. - /// The id of the conversation. - /// Every attribute of every measurement, flattened. - /// - /// The three turns cover the three outcomes: one answers, one meets a section 8.7 row, and one - /// ends the conversation. That reaches every instrument the library owns. - /// - private static async Task>> MeasureAsync(string conversationId) - { - List> tags = []; - using MeterListener listener = new(); - - listener.InstrumentPublished = (instrument, active) => - { - if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) - { - active.EnableMeasurementEvents(instrument); - } - }; - - listener.SetMeasurementEventCallback((_, _, measured, _) => Collect(tags, measured)); - listener.SetMeasurementEventCallback((_, _, measured, _) => Collect(tags, measured)); - listener.Start(); - - using SequencedChatClient reply = new("hello there.", " ", "goodbye."); - using SequencedChatClient fill = new(StayingNull, StayingNull, /*lang=json,strict*/ """{ "callerSaidGoodbye": true }"""); - ConversationSession session = Build(PolicyYaml, reply, fill, auditSink: new InMemoryAuditSink()) - .Create(conversationId); - - CancellationToken token = TestContext.Current.CancellationToken; - _ = await session.RunTurnAsync("hi", token); - _ = await session.RunTurnAsync("still there?", token); - _ = await session.RunTurnAsync("goodbye", token); - - listener.Dispose(); - return tags; - } - - /// Adds the attributes of one measurement to the list. - private static void Collect( - List> tags, - ReadOnlySpan> measured) - { - KeyValuePair[] copy = measured.ToArray(); - lock (tags) - { - tags.AddRange(copy); - } - } - - /// Runs one turn and collects the spans of this library. - private static async Task> RecordSpansAsync(string conversationId) - { - List spans = []; - using ActivityListener listener = ListenTo(spans); - - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(conversationId); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - return [.. Snapshot(spans).Where(span => string.Equals( - span.GetTagItem("gen_ai.conversation.id") as string, conversationId, StringComparison.Ordinal))]; - } - - /// Copies what the listener has collected so far. - /// The list the listener writes into. - /// The copy, which no other thread can change. - /// - /// An subscribes to the source of the whole process, so a span - /// that another test class produces at the same time lands in this list as well. The listener - /// writes under the lock, so a reader takes the same lock and asserts against a copy. Reading - /// the list itself races, and the race reports "Collection was modified". - /// - private static List Snapshot(List spans) - { - lock (spans) - { - return [.. spans]; - } - } - - /// Subscribes to the one activity source of this library. - private static ActivityListener ListenTo(List spans) - { - ActivityListener listener = new() - { - ShouldListenTo = source => - string.Equals(source.Name, AgentCoreTelemetry.ActivitySourceName, StringComparison.Ordinal), - Sample = (ref _) => ActivitySamplingResult.AllData, - ActivityStopped = activity => - { - lock (spans) - { - spans.Add(activity); - } - }, - }; - - ActivitySource.AddActivityListener(listener); - return listener; - } - - private static ConversationSessionFactory Build( - string yaml, - IChatClient reply, - IChatClient? fill, - Func? tools = null, - TimeProvider? timeProvider = null, - IAuditSinkPort? auditSink = null, - ILogger? logger = null) - { - // There is always a sink now: ConversationObservers.Standard takes a required one, because the - // composition root resolves providers.audit for every host and falls back to the in-process - // memory kind. An optional parameter has to be a compile-time constant, so the default is - // spelled here instead — a fact that does not care where its events land gets a fresh - // in-memory sink and reads exactly as it did when it passed nothing. - IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); - - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - RoutingChatClientFactory chatClients = new(reply); - if (fill is not null) - { - _ = chatClients.Route("fill", fill); - } - - CompiledAgent compiled = ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(chatClients) - { - Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), - })["main"]; - - return new ConversationSessionFactory( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, chatClients), - timeProvider, - logger, - ConversationObservers.Standard(sink, logger)); - } - } -} diff --git a/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs b/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs new file mode 100644 index 00000000..ed600dc7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Diagnostics/TurnSpanTests.cs @@ -0,0 +1,103 @@ +using AgentCore.TestSupport; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using Microsoft.Extensions.AI; +using System.Diagnostics; +using Xunit; +using static AgentCore.Application.Tests.Diagnostics.TurnObservabilityHarness; + +namespace AgentCore.Application.Tests.Diagnostics +{ + /// + /// A turn is one span. A high-cardinality value belongs here and nowhere else. + /// + public sealed class TurnSpanTests + { + [Fact] + public async Task ATurn_IsOneSpanThatCarriesTheConversationIdAsAGenAiAttribute() + { + string conversationId = "conversation-" + Guid.NewGuid().ToString("N"); + List spans = await RecordSpansAsync(conversationId); + + Activity span = Assert.Single(spans); + + Assert.Equal(AgentCoreTelemetry.TurnActivityName, span.OperationName); + + // Item 7 asks for gen_ai.* attributes on the trace, and gen_ai.conversation.id is the + // convention's own name for the conversation a request belongs to. A conversation is that + // conversation, and a span attribute costs no series. + Assert.Equal(conversationId, span.GetTagItem("gen_ai.conversation.id")); + Assert.Equal(0, span.GetTagItem("agentcore.turn.index")); + Assert.Equal("greeting", span.GetTagItem("agentcore.stage.before")); + Assert.Equal("greeting", span.GetTagItem("agentcore.stage.after")); + Assert.Equal("completed", span.GetTagItem("agentcore.turn.outcome")); + Assert.Equal(ActivityStatusCode.Unset, span.Status); + } + + [Fact] + public async Task AFailedTurn_MarksItsSpanWithTheReasonOfSectionEightSeven() + { + List spans = []; + using ActivityListener listener = ListenTo(spans); + + using SequencedChatClient reply = new(" "); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-" + Guid.NewGuid().ToString("N")); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Activity span = Assert.Single(Snapshot(spans), item => string.Equals( + item.GetTagItem("gen_ai.conversation.id") as string, session.ConversationId, StringComparison.Ordinal)); + + Assert.Equal("failed", span.GetTagItem("agentcore.turn.outcome")); + Assert.Equal(ActivityStatusCode.Error, span.Status); + + // The span carries only the closed failure-kind token, never ConversationSession.EmptyReplyReason's + // own text: nothing threw here, so there is no exception type to report, and the fixed reason + // stays off the span the same way a raw exception message would (owner ruling: type in spans, + // full error in logs). + Assert.Equal(AgentCoreTelemetry.FailureEmptyReply, span.StatusDescription); + Assert.Equal(AgentCoreTelemetry.FailureEmptyReply, span.GetTagItem("error.type")); + } + + [Fact] + public async Task AStreamedTurn_IsOneSpanToo() + { + List spans = []; + using ActivityListener listener = ListenTo(spans); + + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-" + Guid.NewGuid().ToString("N")); + + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) + { + Assert.NotNull(update); + } + + // The span travels on the turn record and not on Activity.Current, because an async iterator + // restores the execution context of its caller at every yield. + Activity span = Assert.Single(Snapshot(spans), item => string.Equals( + item.GetTagItem("gen_ai.conversation.id") as string, session.ConversationId, StringComparison.Ordinal)); + + Assert.Equal("completed", span.GetTagItem("agentcore.turn.outcome")); + } + + /// Runs one turn and collects the spans of this library. + private static async Task> RecordSpansAsync(string conversationId) + { + List spans = []; + using ActivityListener listener = ListenTo(spans); + + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(conversationId); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + return [.. Snapshot(spans).Where(span => string.Equals( + span.GetTagItem("gen_ai.conversation.id") as string, conversationId, StringComparison.Ordinal))]; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/AfterTaskChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/AfterTaskChatClient.cs new file mode 100644 index 00000000..a9f48aa9 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/AfterTaskChatClient.cs @@ -0,0 +1,31 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// + /// Answers a request that carries a tool result only once completed, and fails it after + /// 10 s. Every other request passes straight through. + /// + /// The model that answers. + /// What must happen before the answer to a tool result. + internal sealed class AfterTaskChatClient(IChatClient inner, Task first) : DelegatingChatClient(inner) + { + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List transcript = [.. messages]; + if (transcript.SelectMany(message => message.Contents).OfType().Any()) + { + await first.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + } + + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(transcript, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/CueToolChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/CueToolChatClient.cs new file mode 100644 index 00000000..f38ebdda --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/CueToolChatClient.cs @@ -0,0 +1,72 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// + /// Calls one offered tool, once, when the user's words since the last reply hold , and answers + /// text otherwise. The tool is the first whose name ends in _add, else the first offered. + /// + /// The words that ask for the tool. + /// Builds the call's arguments. + /// What every other request is answered. + internal sealed class CueToolChatClient(string cue, Func> arguments, string answer = "ok") : IChatClient + { + /// Gets every request this client read, in call order. + public List> Requests { get; } = []; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List transcript = [.. messages]; + lock (Requests) + { + Requests.Add(transcript); + } + + await Task.Yield(); + + string id = Guid.NewGuid().ToString("N"); + bool answered = transcript.Count > 0 && transcript[^1].Contents.OfType().Any(); + int lastReply = transcript.FindLastIndex(message => message.Role == ChatRole.Assistant); + bool cued = transcript.Skip(lastReply + 1) + .Any(message => message.Role == ChatRole.User && message.Text.Contains(cue, StringComparison.Ordinal)); + AIFunction? tool = options?.Tools?.OfType().FirstOrDefault(function => function.Name.EndsWith("_add", StringComparison.Ordinal)) + ?? options?.Tools?.OfType().FirstOrDefault(); + + if (!answered && cued && tool is not null) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_" + id, tool.Name, arguments())]) + { + ResponseId = id, + MessageId = id, + }; + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, answer) { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/DeafChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/DeafChatClient.cs new file mode 100644 index 00000000..2fd4e094 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/DeafChatClient.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// A model that enters its call and stays there, ignoring the cancel, until the test lets it answer. + internal sealed class DeafChatClient : IChatClient + { + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes when the call reaches the model. + public Task Started => _started.Task; + + /// Lets the call answer. + public void Release() + { + _ = _release.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + _ = _started.TrySetResult(); + + await _release.Task.ConfigureAwait(false); + + yield return new ChatResponseUpdate(ChatRole.Assistant, "late"); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + Release(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/FlakyAppendConversationStore.cs b/tests/AgentCore.Application.Tests/Fakes/FlakyAppendConversationStore.cs new file mode 100644 index 00000000..1857395f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/FlakyAppendConversationStore.cs @@ -0,0 +1,49 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; + +namespace AgentCore.Application.Tests.Fakes +{ + /// A store kept in this process whose appends fail while is set, like a backing that goes down for a while. + internal sealed class FlakyAppendConversationStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + /// Gets or sets whether every append fails. + public bool Down { get; set; } + + /// + /// Gets or sets whether every append is saved and then fails, like a commit that landed after which the + /// connection dropped before the answer came back. + /// + public bool SavesThenFails { get; set; } + + /// Gets or sets how many of the next reads of the conversation's record fail. + public int RecordReadsToFail { get; set; } + + public override ValueTask GetAsync(string conversationId, CancellationToken cancellationToken = default) + { + if (RecordReadsToFail > 0) + { + RecordReadsToFail--; + throw new InvalidOperationException("the conversation store cannot be read."); + } + + return base.GetAsync(conversationId, cancellationToken); + } + + public override async ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + if (Down) + { + throw new InvalidOperationException("the conversation store is down."); + } + + IReadOnlyList rows = await base.AppendAsync(conversationId, messages, state, cancellationToken); + return SavesThenFails ? throw new TimeoutException("the answer to a saved append was lost.") : rows; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/GatedChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/GatedChatClient.cs new file mode 100644 index 00000000..37584d30 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/GatedChatClient.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// A model that, once armed, holds its next call until the test opens the gate. + internal sealed class GatedChatClient(IChatClient inner) : DelegatingChatClient(inner) + { + private volatile bool _armed; + + /// Completes when an armed call reaches the gate. + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Opens the gate. + public TaskCompletionSource Open { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Makes every later call wait at the gate. + public void Arm() + { + _armed = true; + } + + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await WaitAtTheGateAsync(cancellationToken).ConfigureAwait(false); + + await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + public override async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + await WaitAtTheGateAsync(cancellationToken).ConfigureAwait(false); + + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + private async Task WaitAtTheGateAsync(CancellationToken cancellationToken) + { + if (_armed) + { + _ = Entered.TrySetResult(); + await Open.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/GatedReadConversationStore.cs b/tests/AgentCore.Application.Tests/Fakes/GatedReadConversationStore.cs new file mode 100644 index 00000000..b8442917 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/GatedReadConversationStore.cs @@ -0,0 +1,37 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; + +namespace AgentCore.Application.Tests.Fakes +{ + /// A store kept in this process whose next session read, once armed, holds until the test opens it. + internal sealed class GatedReadConversationStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + private volatile bool _armed; + + /// Gets what completes when the armed read reaches the gate. + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets what opens the gate. + public TaskCompletionSource Open { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Makes the next session read wait at the gate. + public void Arm() + { + _armed = true; + } + + public override async ValueTask> ReadForSessionAsync( + string conversationId, CancellationToken cancellationToken = default) + { + if (_armed) + { + _armed = false; + _ = Entered.TrySetResult(); + await Open.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return await base.ReadForSessionAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs b/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs new file mode 100644 index 00000000..2be731f7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/HoldingConversationStore.cs @@ -0,0 +1,93 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; + +namespace AgentCore.Application.Tests.Fakes +{ + /// + /// A store kept in this process that holds every record read, or every append, at a gate while the gate is + /// armed, so a test pins one interleaving of a session's reads against its own writes. + /// + internal sealed class HoldingConversationStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + private volatile TaskCompletionSource? _gets; + + private volatile TaskCompletionSource? _appends; + + /// Gets the signal that a record read reached an armed gate. + public TaskCompletionSource GetHeld { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the signal that an append reached an armed gate. + public TaskCompletionSource AppendHeld { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the signal that a session read of the words returned, since armed the gate of record reads. + public TaskCompletionSource SessionRead { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Arms the gate of record reads. + public void HoldGets() + { + SessionRead = new(TaskCreationOptions.RunContinuationsAsynchronously); + _gets = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// Arms the gate of appends. + public void HoldAppends() + { + _appends = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// Disarms the gate of record reads, and lets every held read go on. + public void ReleaseGets() + { + TaskCompletionSource? gate = _gets; + _gets = null; + gate?.TrySetResult(); + } + + /// Disarms the gate of appends, and lets every held append go on. + public void ReleaseAppends() + { + TaskCompletionSource? gate = _appends; + _appends = null; + gate?.TrySetResult(); + } + + /// + public override async ValueTask GetAsync(string conversationId, CancellationToken cancellationToken = default) + { + if (_gets is { } gate) + { + _ = GetHeld.TrySetResult(); + await gate.Task.ConfigureAwait(false); + } + + return await base.GetAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask> ReadForSessionAsync( + string conversationId, CancellationToken cancellationToken = default) + { + IReadOnlyList rows = await base.ReadForSessionAsync(conversationId, cancellationToken).ConfigureAwait(false); + _ = SessionRead.TrySetResult(); + return rows; + } + + /// + public override async ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + if (_appends is { } gate) + { + _ = AppendHeld.TrySetResult(); + await gate.Task.ConfigureAwait(false); + } + + return await base.AppendAsync(conversationId, messages, state, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Fakes/RecordingConversationStore.cs b/tests/AgentCore.Application.Tests/Fakes/RecordingConversationStore.cs index 038c915b..ef39e64d 100644 --- a/tests/AgentCore.Application.Tests/Fakes/RecordingConversationStore.cs +++ b/tests/AgentCore.Application.Tests/Fakes/RecordingConversationStore.cs @@ -17,6 +17,15 @@ internal sealed class RecordingConversationStore() : DelegatingConversationStore /// Gets every row the provider appended, in the order it appended them. public List Rows { get; } = []; + /// Gets how many appends reached the store. One append is one batch, however many rows it holds. + public int Appends { get; private set; } + + /// Gets the state each append carried, one entry per append, in order. + public List States { get; } = []; + + /// Gets or sets a log that every append writes append to, for a test that orders it against other steps. + public List? Log { get; set; } + /// Gets every rewrite the provider asked for, in order. public List Rewrites { get; } = []; @@ -44,6 +53,9 @@ public override async ValueTask> AppendAsync( lock (_lock) { + Appends++; + States.Add(state); + Log?.Add("append"); Rows.AddRange(rows); foreach (ConversationMessage row in rows) { @@ -77,6 +89,25 @@ public override async ValueTask RewriteAsync( } } + /// + public override async ValueTask DeleteMessageAsync( + string conversationId, string messageId, CancellationToken cancellationToken = default) + { + await base.DeleteMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false); + + lock (_lock) + { + foreach ((string ConversationId, int Ordinal) key in _rows.Keys) + { + if (key.ConversationId == conversationId && _rows[key].MessageId == messageId) + { + _ = _rows.Remove(key); + break; + } + } + } + } + /// /// The rows are the ones somebody said: nothing here writes a summary row. public override ValueTask> ReadForSessionAsync( diff --git a/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs index a5f0a848..b13ced2a 100644 --- a/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs +++ b/tests/AgentCore.Application.Tests/Fakes/RequestRecordingChatClient.cs @@ -1,10 +1,11 @@ +using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; namespace AgentCore.Application.Tests.Fakes { /// Answers each request in turn, and keeps what it was asked. /// - /// It drives the buffered path only. is what a fact about the prompt reads: + /// A streamed request is answered in one piece. is what a fact about the prompt reads: /// one role-prefixed line per message, in the order the run sent them. /// internal sealed class RequestRecordingChatClient(params string[] replies) : IChatClient @@ -29,12 +30,16 @@ public Task GetResponseAsync( new ChatResponse(new ChatMessage(ChatRole.Assistant, _replies[Requests.Count - 1]))); } - public IAsyncEnumerable GetStreamingResponseAsync( + public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options = null, - CancellationToken cancellationToken = default) + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - throw new NotSupportedException("These facts drive the buffered path only."); + ChatResponse response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + foreach (ChatResponseUpdate update in response.ToChatResponseUpdates()) + { + yield return update; + } } public object? GetService(Type serviceType, object? serviceKey = null) diff --git a/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs b/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs new file mode 100644 index 00000000..6e4a8a82 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Fakes/TurnScriptChatClient.cs @@ -0,0 +1,140 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Fakes +{ + /// + /// A model that answers each call from a script: text in fragments, one tool call, or a fault. Ported from the + /// design probes' Script (docs/probes/maf-native-engine/ProviderOwnsWritesProbes.cs). + /// + internal sealed class TurnScriptChatClient : IChatClient + { + private readonly List<(string? Tool, bool Fault, string[] Fragments)> _steps; + + private int _calls; + + private TurnScriptChatClient(List<(string?, bool, string[])> steps) + { + _steps = steps; + } + + /// Gets how many calls reached the model. + public int Calls => Volatile.Read(ref _calls); + + /// Gets the messages of every call, in order. + public List> Requests { get; } = []; + + /// Gets or sets the call, from zero, that waits for before it answers; -1 for none. + public int GateBeforeCall { get; set; } = -1; + + /// Gets the signal that the gated call is waiting. + public TaskCompletionSource Gated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the gate the gated call waits on. + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the signal that the first call reached the model. + public TaskCompletionSource Called { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets or sets the pause before each text fragment, in milliseconds. + public int FragmentDelayMs { get; set; } + + /// Answers every call with the same text, in fragments. + public static TurnScriptChatClient Text(params string[] fragments) + { + return new([(null, false, fragments)]); + } + + /// Calls one tool, then answers with text. + public static TurnScriptChatClient ToolThenText(string tool, params string[] fragments) + { + return new([(tool, false, []), (null, false, fragments)]); + } + + /// Calls one tool, then throws. + public static TurnScriptChatClient ToolThenFault(string tool) + { + return new([(tool, false, []), (null, true, [])]); + } + + /// Answers each call with the next reply. + public static TurnScriptChatClient Sequence(params string[][] replies) + { + return new([.. replies.Select(reply => ((string?)null, false, reply))]); + } + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + int index = Interlocked.Increment(ref _calls) - 1; + lock (Requests) + { + Requests.Add([.. messages]); + } + + (string? tool, bool fault, string[] fragments) = _steps[Math.Min(index, _steps.Count - 1)]; + string id = $"m{index}"; + _ = Called.TrySetResult(); + await Task.Yield(); + if (index == GateBeforeCall) + { + _ = Gated.TrySetResult(); + await Release.Task.WaitAsync(cancellationToken); + } + + if (fault) + { + throw new InvalidOperationException("model down"); + } + + if (tool is not null) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, [new FunctionCallContent($"c{index}", tool, new Dictionary())]) + { + MessageId = id, + ResponseId = id, + }; + yield break; + } + + foreach (string fragment in fragments) + { + if (FragmentDelayMs > 0) + { + await Task.Delay(FragmentDelayMs, cancellationToken); + } + + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, fragment) { MessageId = id, ResponseId = id }; + } + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceType.IsInstanceOfType(this) ? this : null; + } + + /// + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs index 67592bfd..35701fe7 100644 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProbeTests.cs @@ -22,7 +22,7 @@ namespace AgentCore.Application.Tests.Knowledge /// /// /// Every test drives the search tool KnowledgeProviderFactory offers, the same way - /// KnowledgeProviderFactoryTests does, rather than through ConversationSession: the probe reads + /// KnowledgeProviderFactorySearchTests does, rather than through ConversationSession: the probe reads /// only the turn (, the knowledge scope, the history flag) and the /// resolved knowledge: block, so filing exactly those by hand proves the same mechanism a /// real conversation would exercise, at a fraction of the setup. The genuinely two-turn and diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryAuditTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryAuditTests.cs new file mode 100644 index 00000000..1923676a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryAuditTests.cs @@ -0,0 +1,132 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Knowledge; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + public sealed class KnowledgeProviderFactoryAuditTests + { + [Fact] + public async Task Create_ASearchThatAnswered_WritesTheRetrievalRecord() + { + // A19. Ruling 21 gave KnowledgeAuditRecord a producer: without one the type shipped tested and + // dead, and a retrieval left no artifact of any kind. + RecordingLoggerFactory loggers = new(); + StubKnowledgePort port = new([Card("a"), Linked("z")]); + + AIContextProvider provider = KnowledgeProviderFactory.Create( + port, Resolved(KnowledgeMode.Prefetch), "analyst", new SourceLocatorCitationFormatter(), loggers); + _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + CapturedLine line = Assert.Single(loggers.Of(11)); + Assert.Equal("analyst", line.Field("Agent")); + Assert.Equal(2, line.Field("CardCount")); + + KnowledgeAuditRecord.LogView? record = line.Field("Record"); + Assert.NotNull(record); + // "link", not "see_also": the audit record says how the card arrived, in its own words, + // rather than repeating one collection's payload key back at the reader. + Assert.Equal(["ranked", "link"], record.Cards.Select(card => card.Via)); + Assert.Equal("analyst", record.Agent); + } + + [Fact] + public async Task Create_ASearchThatThrew_WritesTheCauseTheModelNeverSees() + { + // In tool mode the framework replaces the message with "Error: Function failed.", and in + // prefetch mode this delegate answers a notice rather than throwing. Neither channel carries + // the cause, so an outage during a live conversation turns on this one line existing. + RecordingLoggerFactory loggers = new(); + InvalidOperationException down = new("qdrant is down"); + + AIContextProvider provider = KnowledgeProviderFactory.Create( + new ThrowingKnowledgePort(down), Resolved(KnowledgeMode.Prefetch), "resolver", new SourceLocatorCitationFormatter(), loggers); + _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + CapturedLine line = Assert.Single(loggers.Of(12)); + Assert.Equal(LogLevel.Error, line.Level); + Assert.Equal("resolver", line.Field("Agent")); + + // The cause travels as the log's own exception argument, which is where a structured sink + // stores a stack trace. Ruling 22 took the duplicate copy off the record's log view rather + // than write the same trace into a message field as well. + Assert.Same(down, line.Exception); + Assert.Contains("qdrant is down", line.Exception!.ToString(), StringComparison.Ordinal); + + KnowledgeAuditRecord.LogView? record = line.Field("Record"); + Assert.NotNull(record); + Assert.Equal("resolver", record.Agent); + } + + [Fact] + public async Task Create_NeitherLoggedRow_CarriesWhatTheCallerSaid() + { + // Ruling 22, and a regression this branch introduced. Query is the framework-composed search + // input: the caller's current utterance plus, at RecentMessageMemoryLimit = 4, up to four + // earlier messages. The failure row is an Error, which a default production configuration + // keeps on, so logging the record whole copied every caller's words into a log store once per + // agent per turn for as long as an outage lasted -- the exact thing Log.PromptRefused refuses + // in the same file. + const string spoken = "my ct900 shows e33 and my name is jane quimby on account 40771"; + + RecordingLoggerFactory loggers = new(); + + AIContextProvider answered = KnowledgeProviderFactory.Create( + new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch), "analyst", new SourceLocatorCitationFormatter(), loggers); + _ = await InvokePrefetchAsync(answered, spoken, PrefetchTurn(), new StubSession()); + + AIContextProvider threw = KnowledgeProviderFactory.Create( + new ThrowingKnowledgePort(new InvalidOperationException("qdrant is down")), + Resolved(KnowledgeMode.Prefetch), + "resolver", + new SourceLocatorCitationFormatter(), + loggers); + _ = await InvokePrefetchAsync(threw, spoken, PrefetchTurn(), new StubSession()); + + // Both rows were written -- a test that logged nothing would pass the assertions below + // vacuously, and this is exactly the fact that must not pass vacuously. + _ = Assert.Single(loggers.Of(11)); + _ = Assert.Single(loggers.Of(12)); + + foreach (CapturedLine line in loggers.Lines) + { + // The formatted text AND every structured field: a sink reads the fields, not the string. + Assert.DoesNotContain(spoken, line.Message, StringComparison.Ordinal); + Assert.DoesNotContain(spoken, line.Exception?.ToString() ?? string.Empty, StringComparison.Ordinal); + + foreach (KeyValuePair field in line.Fields) + { + Assert.DoesNotContain( + spoken, field.Value?.ToString() ?? string.Empty, StringComparison.Ordinal); + } + } + } + + [Fact] + public async Task Create_TheLoggedRow_CarriesTheQuerysLengthInstead() + { + // What replaces the text, and why a length rather than a hash: an outage is diagnosed by + // whether the input was well formed. A zero-length query is a provider bug and a runaway one + // is the recent-message concatenation gone wrong; a length answers both, and a hash answers + // only "the same text again" while still being a per-caller correlation handle. + const string spoken = "the screen says e33"; + + RecordingLoggerFactory loggers = new(); + + AIContextProvider provider = KnowledgeProviderFactory.Create( + new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch), "analyst", new SourceLocatorCitationFormatter(), loggers); + _ = await InvokePrefetchAsync(provider, spoken, PrefetchTurn(), new StubSession()); + + KnowledgeAuditRecord.LogView? view = Assert.Single(loggers.Of(11)).Field("Record"); + Assert.NotNull(view); + Assert.Equal(spoken.Length, view.QueryLength); + Assert.Equal("analyst", view.Agent); + Assert.Equal(KnowledgeMode.Prefetch, view.Mode); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs new file mode 100644 index 00000000..97804783 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryCitationTests.cs @@ -0,0 +1,95 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + public sealed class KnowledgeProviderFactoryCitationTests + { + [Fact] + public async Task Create_CitationsOff_AsksForNoCitationAtAll() + { + // A null CitationsPrompt is not "off": it produces the framework's own default block, + // which asks for a document name and link, and there is never a link. + AIContextProvider provider = Provider( + new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch, citations: false)); + + AIContext context = await InvokePrefetchAsync( + provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + string text = Merged(context); + Assert.DoesNotContain("Include citations", text, StringComparison.Ordinal); + Assert.DoesNotContain("ct900-om", text, StringComparison.Ordinal); + } + + [Fact] + public async Task Create_CitationsOn_NamesTheSourceAndForbidsAnInventedLink() + { + AIContextProvider provider = Provider( + new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch, citations: true)); + + AIContext context = await InvokePrefetchAsync( + provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + string text = Merged(context); + Assert.Contains("ct900-om, p.27", text, StringComparison.Ordinal); + Assert.DoesNotContain("Include citations", text, StringComparison.Ordinal); + Assert.Contains("Do not invent a link", text, StringComparison.Ordinal); + } + + [Fact] + public async Task Create_CitationsOn_PublishesASourceForEachCardShown() + { + // Task 5 fix round 1, Finding 1. Nothing in this suite drove the sources port to a + // non-null value for a citing search before this test, so the body of the publish loop had + // never executed. A collector plus an outer call, both open around the same InvokingAsync + // the other tests already drive, is what proves the wiring rather than just reading it. + StubKnowledgePort port = new([Card("a"), Card("b")]); + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, citations: true)); + + TurnSources sources = new(); + + StubSession session = new(); + TurnRegistry.Set(session, PrefetchTurn(sources: sources)); + using (sources.BeginOuterCall("conversation-1")) + { + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); + } + + IReadOnlyList cited = sources.TakeFor("conversation-1"); + Assert.Equal(2, cited.Count); + Assert.Equal( + ["a", "b"], cited.Select(content => content.Source.SourceId).OrderBy(id => id)); + Assert.All(cited, content => Assert.Equal("ct900-om, p.27", content.Source.Title)); + } + + [Fact] + public async Task Create_CitationsOff_PublishesNothing() + { + // The leak test, and the most important one in this task. citations: false is the single + // switch a deployment uses to decide whether it discloses its sources at all -- a chip on + // screen is a disclosure just as much as the label the model is shown -- so this must + // publish nothing even with a collector and an outer call both open and cards coming back. + StubKnowledgePort port = new([Card("a"), Card("b")]); + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, citations: false)); + + TurnSources sources = new(); + + StubSession session = new(); + TurnRegistry.Set(session, PrefetchTurn(sources: sources)); + using (sources.BeginOuterCall("conversation-1")) + { + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); + } + + Assert.Empty(sources.TakeFor("conversation-1")); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs new file mode 100644 index 00000000..6cb2e309 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryLimitTests.cs @@ -0,0 +1,83 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + public sealed class KnowledgeProviderFactoryLimitTests + { + [Theory] + [InlineData(2, "card b", "card c")] + [InlineData(3, "card c", "card d")] + public async Task Create_MoreCardsThanTheAgentAsksFor_KeepsTheBestFew( + int limit, string lastKept, string firstDropped) + { + // Ruling 14(c). The store fetches once, up to its own deployment ceiling, for every agent. + // limit: is this agent's view of that fetch, and the port returns cards best first. + // Two limits, because one cannot tell the agent's limit from a hardcoded number. + StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Card("d"), Card("e")]); + + AIContextProvider provider = Provider( + port, Resolved(KnowledgeMode.Prefetch, limit: limit)); + AIContext context = await InvokePrefetchAsync( + provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + string text = Merged(context); + Assert.Contains("card a", text, StringComparison.Ordinal); + Assert.Contains(lastKept, text, StringComparison.Ordinal); + Assert.DoesNotContain(firstDropped, text, StringComparison.Ordinal); + Assert.DoesNotContain("card e", text, StringComparison.Ordinal); + } + + [Fact] + public async Task Create_TheLimit_CountsRankedCardsAndLetsALinkedOneRideAlong() + { + // Ruling 16. A7 makes see_also expansion never optional, and the store appends the linked + // cards after every scored one. A plain prefix would drop every link whenever the fetch + // filled up -- which, at the shipped defaults of 5 and 5, is every full result. The winning + // probe arm was "top 5 plus see_also of the top hit": the links are additional to the five. + StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Linked("z")]); + + AIContextProvider provider = Provider( + port, Resolved(KnowledgeMode.Prefetch, limit: 2)); + AIContext context = await InvokePrefetchAsync( + provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + string text = Merged(context); + Assert.Contains("card a", text, StringComparison.Ordinal); + Assert.Contains("card b", text, StringComparison.Ordinal); + Assert.DoesNotContain("card c", text, StringComparison.Ordinal); + Assert.Contains("card z", text, StringComparison.Ordinal); + } + + [Fact] + public async Task Create_MoreCardsThanTheLimit_CitesOnlyTheCardsTheModelIsShown() + { + // Finding 2. A card ranked past the agent's limit: is never shown to the model, so citing + // it anyway would put a chip on screen for a document the answer could not possibly have + // drawn on. The cited set must match the kept set, not the store's full return. + StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Card("d"), Card("e")]); + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, limit: 2, citations: true)); + + TurnSources sources = new(); + + StubSession session = new(); + TurnRegistry.Set(session, PrefetchTurn(sources: sources)); + using (sources.BeginOuterCall("conversation-1")) + { + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); + } + + IReadOnlyList cited = sources.TakeFor("conversation-1"); + Assert.Equal(2, cited.Count); + Assert.Equal( + ["a", "b"], cited.Select(content => content.Source.SourceId).OrderBy(id => id)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs new file mode 100644 index 00000000..78d58c7c --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryModeTests.cs @@ -0,0 +1,141 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Knowledge; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + /// + /// The layer that decides, per agent, how retrieval reaches the model — and the only layer that can + /// enforce the two per-agent settings the one-method port cannot carry. + /// + public sealed class KnowledgeProviderFactoryModeTests + { + [Fact] + public async Task Create_PrefetchMode_RetrievesBeforeTheModelIsCalled() + { + StubKnowledgePort port = new([Card("a"), Card("b")]); + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch)); + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + Assert.Null(context.Tools); + Assert.Equal("the screen says e33", port.LastQuery); + Assert.Contains("card a", Merged(context), StringComparison.Ordinal); + Assert.Contains("card b", Merged(context), StringComparison.Ordinal); + } + + [Fact] + public async Task Create_ToolMode_OffersASearchToolInstead() + { + StubKnowledgePort port = new([Card("a")]); + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Tool)); + AIContext context = await provider.InvokingAsync( + Invoking("hello", new StubSession()), TestContext.Current.CancellationToken); + + Assert.NotNull(context.Tools); + _ = Assert.Single(context.Tools); + Assert.Equal(0, port.Calls); + } + + [Fact] + public async Task Create_PortThrows_InjectsANoticeAndDoesNotFailTheTurn() + { + // A16. The framework's own behaviour here is SILENT fail-open: a throwing delegate + // injects nothing at all and logs nothing, and the model then answers "E03 means + // overheating" from its own weights. So the delegate must catch and say so. + AIContextProvider provider = Provider( + new ThrowingKnowledgePort(new InvalidOperationException("qdrant is down")), + Resolved(KnowledgeMode.Prefetch)); + + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + string text = Merged(context); + Assert.Contains("knowledge base", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("unreachable", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("do not answer from memory", text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Create_TheStoresOwnDeadlineFires_InjectsTheNoticeAndWritesTheCause() + { + // A16 asks this delegate to catch every exception AND its own deadline. The store links that + // deadline into the caller's token, and the channel reports a cancelled gRPC call as + // OperationCanceledException -- so a hung Qdrant arrives as the same type a caller cancel + // does. Excluding the type outright let the deadline straight through: no notice, no record, + // no log line, and a model answering "E33 means the incline motor" from its own weights. + RecordingLoggerFactory loggers = new(); + + AIContextProvider provider = KnowledgeProviderFactory.Create( + new HangingKnowledgePort(TimeSpan.FromMilliseconds(20)), + Resolved(KnowledgeMode.Prefetch), + "resolver", + new SourceLocatorCitationFormatter(), + loggers); + + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + string text = Merged(context); + Assert.Contains("unreachable", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("do not answer from memory", text, StringComparison.OrdinalIgnoreCase); + + CapturedLine line = Assert.Single(loggers.Of(12)); + Assert.Equal(LogLevel.Error, line.Level); + Assert.Equal("resolver", line.Field("Agent")); + _ = Assert.IsType(line.Exception, exactMatch: false); + } + + [Fact] + public async Task Create_TheCallerCancels_IsNotDressedUpAsARetrievalFailure() + { + // The other half of the same classifier, and the reason it cannot simply catch the type: a + // hung-up caller produces no answer, so a notice has nowhere to land and an Error row would + // fire on every abandoned turn of every agent. The port raises its exception carrying the + // LINKED token here, exactly as the real store does, so nothing but the caller's own token + // separates this case from the deadline above. + RecordingLoggerFactory loggers = new(); + using CancellationTokenSource caller = new(); + + AIContextProvider provider = KnowledgeProviderFactory.Create( + new HangingKnowledgePort(TimeSpan.FromMinutes(5)), + Resolved(KnowledgeMode.Prefetch), + "resolver", + new SourceLocatorCitationFormatter(), + loggers); + + caller.CancelAfter(TimeSpan.FromMilliseconds(20)); + + StubSession callerSession = new(); + TurnRegistry.Set(callerSession, PrefetchTurn()); + + // The framework swallows whatever escapes the delegate, so "propagated" is read off what did + // NOT happen: no notice, and neither the success row nor the failure row. A delegate that + // returned normally would have written row 11, and one that took the failure path row 12. + AIContext context = await provider.InvokingAsync(Invoking("the screen says e33", callerSession), caller.Token); + + Assert.DoesNotContain("unreachable", Merged(context), StringComparison.OrdinalIgnoreCase); + Assert.Empty(loggers.Of(11)); + Assert.Empty(loggers.Of(12)); + } + + [Fact] + public async Task Create_PortReturnsNothing_InjectsNothing() + { + // The score floor is the gate. An empty list must not produce an empty "Additional + // Context" block that costs tokens and says nothing. + AIContextProvider provider = Provider( + new StubKnowledgePort([]), Resolved(KnowledgeMode.Prefetch)); + + AIContext context = await InvokePrefetchAsync(provider, "hello", PrefetchTurn(), new StubSession()); + + Assert.Equal(["hello"], Texts(context)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryQueryTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryQueryTests.cs new file mode 100644 index 00000000..9419519a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryQueryTests.cs @@ -0,0 +1,78 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Knowledge.Fakes; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + public sealed class KnowledgeProviderFactoryQueryTests + { + [Fact] + public async Task Create_WhatAnEarlierProviderInjected_IsNotPartOfTheQuery() + { + // The knowledge provider is bound second, after TurnContextProvider, and the framework + // hands each provider what the one before it produced. The turn's own instructions are not + // a query, and a search over them retrieves the wrong cards. + StubKnowledgePort port = new([]); + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch)); + StubSession session = new(); + TurnRegistry.Set(session, PrefetchTurn()); + +#pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. + AIContextProvider.InvokingContext context = new( + StubAgent.Instance, + session, + new AIContext + { + Messages = + [ + new ChatMessage(ChatRole.User, "the screen says e33"), + new ChatMessage(ChatRole.System, "ask the caller for the machine model") + .WithAgentRequestMessageSource( + AgentRequestMessageSourceType.AIContextProvider, "TurnContextProvider"), + ], + }); +#pragma warning restore MAAI001 + + _ = await provider.InvokingAsync(context, TestContext.Current.CancellationToken); + + Assert.Equal("the screen says e33", port.LastQuery); + } + + [Fact] + public async Task Create_ASecondTurn_SearchesWithWhatTheCallerSaidBefore() + { + // RecentMessageMemoryLimit defaults to 0, and at 0 turn two's query is the bare new + // message: "does it need a part?" resolves its pronoun against nothing. + StubKnowledgePort port = new([Card("a")]); + StubSession session = new(); + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch)); + TurnRegistry.Set(session, PrefetchTurn()); + _ = await provider.InvokingAsync( + Invoking("the screen says e33", session), TestContext.Current.CancellationToken); + await provider.InvokedAsync( + Invoked("the screen says e33", session), TestContext.Current.CancellationToken); + _ = await provider.InvokingAsync( + Invoking("does it need a part?", session), TestContext.Current.CancellationToken); + + Assert.Contains("the screen says e33", port.LastQuery!, StringComparison.Ordinal); + Assert.Contains("does it need a part?", port.LastQuery!, StringComparison.Ordinal); + } + + /// Closes a turn, so the provider stores what it should remember of it. + private static AIContextProvider.InvokedContext Invoked(string text, AgentSession session) + { +#pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. + return new( + StubAgent.Instance, + session, + [new ChatMessage(ChatRole.User, text)], + [new ChatMessage(ChatRole.Assistant, "let me look")]); +#pragma warning restore MAAI001 + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs new file mode 100644 index 00000000..a7f529c1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryScopeTests.cs @@ -0,0 +1,150 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.Domain.Knowledge; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + public sealed class KnowledgeProviderFactoryScopeTests + { + [Fact] + public async Task Create_ScopedAgentWithNoAmbientScope_NeverReachesThePort() + { + // Ruling 14(b). The store is shared, so it stays permissive whenever one agent is unscoped. + // Without this gate, scoped: true on THIS agent would serve every customer every card. + StubKnowledgePort port = new([Card("a"), Card("b")]); + + AIContextProvider provider = Provider( + port, Resolved(KnowledgeMode.Prefetch, scoped: true)); + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + Assert.Equal(0, port.Calls); + Assert.DoesNotContain("card a", Merged(context), StringComparison.Ordinal); + Assert.Contains("no scope is open", Merged(context), StringComparison.OrdinalIgnoreCase); + Assert.Contains("do not answer from memory", Merged(context), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Create_ScopedAgentWithAnEmptyScope_NeverReachesThePort() + { + // An ambient with no facets filters nothing, so it is the absent ambient wearing a hat. + StubKnowledgePort port = new([Card("a"), Card("b")]); + KnowledgeScope scope = new() { Facets = new Dictionary() }; + + AIContextProvider provider = Provider( + port, Resolved(KnowledgeMode.Prefetch, scoped: true)); + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + Assert.Equal(0, port.Calls); + Assert.Contains("no scope is open", Merged(context), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Create_ScopedAgentWithAScopeOpen_Searches() + { + StubKnowledgePort port = new([Card("a")]); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + AIContextProvider provider = Provider( + port, Resolved(KnowledgeMode.Prefetch, scoped: true)); + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + Assert.Equal(1, port.Calls); + Assert.Contains("card a", Merged(context), StringComparison.Ordinal); + } + + [Fact] + public async Task Create_UnscopedAgentWithNoAmbientScope_StillSearches() + { + // The gate is keyed on the agent's own flag, not on the store's. An unscoped agent in a + // mixed deployment must keep working when no host opened a scope. + StubKnowledgePort port = new([Card("a")]); + + AIContextProvider provider = Provider( + port, Resolved(KnowledgeMode.Prefetch, scoped: false)); + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + Assert.Equal(1, port.Calls); + Assert.Contains("card a", Merged(context), StringComparison.Ordinal); + } + + [Fact] + public async Task Create_UnscopedAgentUnderAScopedHostAmbient_StillSearchesTheWholeCorpus() + { + // Ruling 20, and the defect it resolves. example.yaml ships a mixed deployment: the resolver + // requires a scope, so the host sets ct900 for the whole conversation, and the analyst -- which the + // same document says "searches every product on purpose" -- was silently filtered to ct900 + // with it. The store folds whatever scope it is handed into the filter, so the turn's scope + // has to stop here for an unscoped agent. + ScopeFilteringKnowledgePort port = new( + (Card("ct900"), Facets("ct900")), + (Card("ent"), Facets("ct900ent"))); + + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: false)); + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + // The card OUTSIDE the host's facet is the whole point: an unscoped agent sees it. + Assert.Contains("card ent", Merged(context), StringComparison.Ordinal); + Assert.Contains("card ct900", Merged(context), StringComparison.Ordinal); + } + + [Fact] + public async Task Create_UnscopedAgent_ReachesTheStoreUnderAnEmptyScopeRatherThanTheHosts() + { + // The mechanism behind the fact above, asserted where the store reads it. An empty facet map + // adds no filter condition, so this is a whole-corpus read and not a differently-shaped one. + StubKnowledgePort port = new([Card("a")]); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: false)); + _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + Assert.NotNull(port.ScopeAtTheStore); + Assert.Empty(port.ScopeAtTheStore.Facets); + } + + [Fact] + public async Task Create_ScopedAgent_ReachesTheStoreUnderTheHostsOwnScope() + { + // The counterpart. Taking the scope away from the unscoped agent must not take it away from + // the scoped one, which is the agent the scope exists for. + StubKnowledgePort port = new([Card("a")]); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: true)); + _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + Assert.Same(scope, port.ScopeAtTheStore); + } + + [Fact] + public async Task Create_UnscopedAgent_PutsTheHostsScopeBackAfterTheSearch() + { + // The empty scope covers one port conversation and nothing else. Leaking it would silently unscope the + // scoped agent that runs next -- the very leak this whole design fails closed against, + // arriving from the inside. + StubKnowledgePort port = new([Card("a")]); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: false)); + _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); + + // The empty scope covered that one search only: a scoped turn still resolves the host's scope. + AIContextProvider scoped = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: true)); + _ = await InvokePrefetchAsync(scoped, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + Assert.Same(scope, port.ScopeAtTheStore); + } + + /// The facet map one card of the scope-filtering corpus carries. + private static Dictionary Facets(string model) + { + return new(StringComparer.Ordinal) { ["model"] = model }; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs new file mode 100644 index 00000000..32a2e69f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactorySearchTests.cs @@ -0,0 +1,124 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Knowledge.Fakes; +using AgentCore.Domain.Knowledge; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Knowledge.KnowledgeProviderFactoryTestSupport; + +namespace AgentCore.Application.Tests.Knowledge +{ + public sealed class KnowledgeProviderFactorySearchTests + { + [Fact] + public async Task Search_ToolModeWithNoResults_InjectsTheEmptyNotice() + { + AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Tool, scoped: true)); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + IReadOnlyList results = await InvokeSearchAsync(provider, "f63 error e03", ToolTurn(scope)); + + Assert.Contains(results, r => r.Text.Contains("holds nothing", StringComparison.Ordinal)); + } + + [Fact] + public async Task Search_PrefetchModeWithNoResults_InjectsNothing() + { + AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Prefetch)); + + AIContext context = await InvokePrefetchAsync(provider, "hello", PrefetchTurn(), new StubSession()); + + Assert.Equal(["hello"], Texts(context)); + } + + [Fact] + public async Task Search_ScopedPrefetchModeWithNoResults_InjectsNothing() + { + // Both halves of the notice guard matter. A scoped agent with a real facet open already + // clears the Facets.Count > 0 half, so only the KnowledgeMode.Tool clause keeps a prefetch + // agent's empty search silent. + AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Prefetch, scoped: true)); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); + + Assert.Equal(["the screen says e33"], Texts(context)); + } + + [Fact] + public async Task Search_UnscopedToolModeWithNoResults_InjectsNothing() + { + // scoped: false opens WholeCorpus, which holds no facets. Such an agent's empty search stays + // byte-identical to an unscoped one: an empty list, no notice. + AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Tool, scoped: false)); + + IReadOnlyList results = await InvokeSearchAsync(provider, "f63 error e03", ToolTurn()); + + Assert.Empty(results); + } + + [Fact] + public async Task Search_EveryNotice_CarriesTheReservedSourceName() + { + // A notice must never be citable as a card, and the reserved source name is what stops it. + AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Tool, scoped: true)); + KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; + + IReadOnlyList results = await InvokeSearchAsync(provider, "f63 error e03", ToolTurn(scope)); + + Assert.All(results, r => Assert.Equal("agentcore:notice", r.SourceName)); + } + + [Fact] + public async Task Search_ToolModeThatThrows_StillSaysUnreachable() + { + AIContextProvider provider = Provider( + new ThrowingKnowledgePort(new InvalidOperationException("boom")), Resolved(KnowledgeMode.Tool)); + + IReadOnlyList results = await InvokeSearchAsync(provider, "f63", ToolTurn()); + + Assert.Contains(results, r => r.Text.Contains("unreachable", StringComparison.Ordinal)); + } + + private static TurnInvocation ToolTurn( + KnowledgeScope? scope = null, Clarifications? clarifications = null, TurnSources? sources = null) + { + return new() + { + ConversationId = "conversation", + TurnIndex = 0, + Stage = "", + Knowledge = scope, + Clarifications = clarifications ?? new Clarifications(), + Sources = sources, + }; + } + + /// + /// Invokes the search tool the provider offers, the way the model would: the turn rides along + /// as an argument, carrying the scope and the clarifications the search runs under. + /// + /// The provider built. + /// The search text. + /// The turn the search runs under. + /// What the search returned. + private static async Task> InvokeSearchAsync( + AIContextProvider provider, string query, TurnInvocation turn) + { + StubSession session = new(); + AIContext context = await provider.InvokingAsync( + Invoking("hello", session), TestContext.Current.CancellationToken).ConfigureAwait(false); + AITool search = Assert.Single(context.Tools!, tool => tool.Name == "Search"); + IReadOnlyList? results = await ((AIFunction)search).InvokeAsync( + turn.FileIn(new AIFunctionArguments(new Dictionary(StringComparer.Ordinal) + { + ["userQuestion"] = query, + })), + TestContext.Current.CancellationToken).ConfigureAwait(false) + as IReadOnlyList; + return results!; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs new file mode 100644 index 00000000..6f2f2c91 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTestSupport.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Knowledge; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Domain.Knowledge; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Knowledge +{ + /// What every KnowledgeProviderFactory test class shares: building the turn, the + /// provider, the framework's own invoke shapes, and the stub session/agent pair none of them run. + internal static class KnowledgeProviderFactoryTestSupport + { + /// Builds the provider under test, with the agent id and logger these facts do not read. + /// The store the provider searches. + /// The agent's resolved knowledge: block. + /// The provider. + internal static TurnInvocation PrefetchTurn(KnowledgeScope? scope = null, TurnSources? sources = null) + { + return new() + { + ConversationId = "conversation", + TurnIndex = 0, + Stage = "", + Knowledge = scope, + Sources = sources, + }; + } + + internal static async Task InvokePrefetchAsync( + AIContextProvider provider, string text, TurnInvocation turn, AgentSession session) + { + TurnRegistry.Set(session, turn); + return await provider.InvokingAsync( + Invoking(text, session), TestContext.Current.CancellationToken).ConfigureAwait(false); + } + + internal static AIContextProvider Provider(IKnowledgeRetrievalPort port, ResolvedKnowledge knowledge) + { + return KnowledgeProviderFactory.Create( + port, knowledge, "agent-under-test", new SourceLocatorCitationFormatter(), loggers: null); + } + + /// Every message text of a returned context, in one string. + internal static string Merged(AIContext context) + { + return string.Join('\n', Texts(context)); + } + + internal static IEnumerable Texts(AIContext context) + { + return (context.Messages ?? []).Select(message => message.Text); + } + + internal static ResolvedKnowledge Resolved( + KnowledgeMode mode, int limit = 5, bool citations = false, bool scoped = false) + { + return new(mode, limit, citations, scoped); + } + + internal static KnowledgeCard Card(string id) + { + return new() + { + CardId = id, + Text = "card " + id, + Authority = 3, + SourceRef = "ct900-om", + SourceLocator = "p.27", + Score = 0.87, + ViaLink = false, + }; + } + + /// A card see_also pulled in. The store appends these after every scored card. + internal static KnowledgeCard Linked(string id) + { + return Card(id) with { Score = null, ViaLink = true }; + } + + /// Runs the provider the way the framework runs it, over one caller message. + internal static AIContextProvider.InvokingContext Invoking(string text, AgentSession session) + { +#pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. + return new( + StubAgent.Instance, + session, + new AIContext { Messages = [new ChatMessage(ChatRole.User, text)] }); +#pragma warning restore MAAI001 + } + + internal sealed class StubSession : AgentSession; + + /// Stands in for the agent the framework names on a context. Nothing here runs it. + internal sealed class StubAgent : AIAgent + { + public static StubAgent Instance { get; } = new(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) + { + return new(new StubSession()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTests.cs b/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTests.cs deleted file mode 100644 index b6069c3c..00000000 --- a/tests/AgentCore.Application.Tests/Knowledge/KnowledgeProviderFactoryTests.cs +++ /dev/null @@ -1,856 +0,0 @@ -using System.Text.Json; -using AgentCore.TestSupport; -using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Knowledge; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Application.Runtime.Turn; -using AgentCore.Application.Tests.Knowledge.Fakes; -using AgentCore.Domain.Knowledge; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Xunit; -using AgentCore.Application.Transcript; - -namespace AgentCore.Application.Tests.Knowledge -{ - /// - /// The layer that decides, per agent, how retrieval reaches the model — and the only layer that can - /// enforce the two per-agent settings the one-method port cannot carry. - /// - /// - /// InvokingAsync returns the input context merged with what the provider added, so a test - /// that reads sees the caller's own message as well. "Injected - /// nothing" is therefore "the input message and no more". - /// - public sealed class KnowledgeProviderFactoryTests - { - [Fact] - public async Task Create_PrefetchMode_RetrievesBeforeTheModelIsCalled() - { - StubKnowledgePort port = new([Card("a"), Card("b")]); - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch)); - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - Assert.Null(context.Tools); - Assert.Equal("the screen says e33", port.LastQuery); - Assert.Contains("card a", Merged(context), StringComparison.Ordinal); - Assert.Contains("card b", Merged(context), StringComparison.Ordinal); - } - - [Fact] - public async Task Create_ToolMode_OffersASearchToolInstead() - { - StubKnowledgePort port = new([Card("a")]); - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Tool)); - AIContext context = await provider.InvokingAsync( - Invoking("hello", new StubSession()), TestContext.Current.CancellationToken); - - Assert.NotNull(context.Tools); - _ = Assert.Single(context.Tools); - Assert.Equal(0, port.Calls); - } - - [Fact] - public async Task Create_PortThrows_InjectsANoticeAndDoesNotFailTheTurn() - { - // A16. The framework's own behaviour here is SILENT fail-open: a throwing delegate - // injects nothing at all and logs nothing, and the model then answers "E03 means - // overheating" from its own weights. So the delegate must catch and say so. - AIContextProvider provider = Provider( - new ThrowingKnowledgePort(new InvalidOperationException("qdrant is down")), - Resolved(KnowledgeMode.Prefetch)); - - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - string text = Merged(context); - Assert.Contains("knowledge base", text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("unreachable", text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("do not answer from memory", text, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Create_TheStoresOwnDeadlineFires_InjectsTheNoticeAndWritesTheCause() - { - // A16 asks this delegate to catch every exception AND its own deadline. The store links that - // deadline into the caller's token, and the channel reports a cancelled gRPC call as - // OperationCanceledException -- so a hung Qdrant arrives as the same type a caller cancel - // does. Excluding the type outright let the deadline straight through: no notice, no record, - // no log line, and a model answering "E33 means the incline motor" from its own weights. - RecordingLoggerFactory loggers = new(); - - AIContextProvider provider = KnowledgeProviderFactory.Create( - new HangingKnowledgePort(TimeSpan.FromMilliseconds(20)), - Resolved(KnowledgeMode.Prefetch), - "resolver", - new SourceLocatorCitationFormatter(), - loggers); - - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - string text = Merged(context); - Assert.Contains("unreachable", text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("do not answer from memory", text, StringComparison.OrdinalIgnoreCase); - - CapturedLine line = Assert.Single(loggers.Of(12)); - Assert.Equal(LogLevel.Error, line.Level); - Assert.Equal("resolver", line.Field("Agent")); - _ = Assert.IsType(line.Exception, exactMatch: false); - } - - [Fact] - public async Task Create_TheCallerCancels_IsNotDressedUpAsARetrievalFailure() - { - // The other half of the same classifier, and the reason it cannot simply catch the type: a - // hung-up caller produces no answer, so a notice has nowhere to land and an Error row would - // fire on every abandoned turn of every agent. The port raises its exception carrying the - // LINKED token here, exactly as the real store does, so nothing but the caller's own token - // separates this case from the deadline above. - RecordingLoggerFactory loggers = new(); - using CancellationTokenSource caller = new(); - - AIContextProvider provider = KnowledgeProviderFactory.Create( - new HangingKnowledgePort(TimeSpan.FromMinutes(5)), - Resolved(KnowledgeMode.Prefetch), - "resolver", - new SourceLocatorCitationFormatter(), - loggers); - - caller.CancelAfter(TimeSpan.FromMilliseconds(20)); - - StubSession callerSession = new(); - TurnRegistry.Set(callerSession, PrefetchTurn()); - - // The framework swallows whatever escapes the delegate, so "propagated" is read off what did - // NOT happen: no notice, and neither the success row nor the failure row. A delegate that - // returned normally would have written row 11, and one that took the failure path row 12. - AIContext context = await provider.InvokingAsync(Invoking("the screen says e33", callerSession), caller.Token); - - Assert.DoesNotContain("unreachable", Merged(context), StringComparison.OrdinalIgnoreCase); - Assert.Empty(loggers.Of(11)); - Assert.Empty(loggers.Of(12)); - } - - [Fact] - public async Task Create_PortReturnsNothing_InjectsNothing() - { - // The score floor is the gate. An empty list must not produce an empty "Additional - // Context" block that costs tokens and says nothing. - AIContextProvider provider = Provider( - new StubKnowledgePort([]), Resolved(KnowledgeMode.Prefetch)); - - AIContext context = await InvokePrefetchAsync(provider, "hello", PrefetchTurn(), new StubSession()); - - Assert.Equal(["hello"], Texts(context)); - } - - [Fact] - public async Task Create_ScopedAgentWithNoAmbientScope_NeverReachesThePort() - { - // Ruling 14(b). The store is shared, so it stays permissive whenever one agent is unscoped. - // Without this gate, scoped: true on THIS agent would serve every customer every card. - StubKnowledgePort port = new([Card("a"), Card("b")]); - - AIContextProvider provider = Provider( - port, Resolved(KnowledgeMode.Prefetch, scoped: true)); - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - Assert.Equal(0, port.Calls); - Assert.DoesNotContain("card a", Merged(context), StringComparison.Ordinal); - Assert.Contains("no scope is open", Merged(context), StringComparison.OrdinalIgnoreCase); - Assert.Contains("do not answer from memory", Merged(context), StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Create_ScopedAgentWithAnEmptyScope_NeverReachesThePort() - { - // An ambient with no facets filters nothing, so it is the absent ambient wearing a hat. - StubKnowledgePort port = new([Card("a"), Card("b")]); - KnowledgeScope scope = new() { Facets = new Dictionary() }; - - AIContextProvider provider = Provider( - port, Resolved(KnowledgeMode.Prefetch, scoped: true)); - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - Assert.Equal(0, port.Calls); - Assert.Contains("no scope is open", Merged(context), StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Create_ScopedAgentWithAScopeOpen_Searches() - { - StubKnowledgePort port = new([Card("a")]); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - AIContextProvider provider = Provider( - port, Resolved(KnowledgeMode.Prefetch, scoped: true)); - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - Assert.Equal(1, port.Calls); - Assert.Contains("card a", Merged(context), StringComparison.Ordinal); - } - - [Fact] - public async Task Create_UnscopedAgentWithNoAmbientScope_StillSearches() - { - // The gate is keyed on the agent's own flag, not on the store's. An unscoped agent in a - // mixed deployment must keep working when no host opened a scope. - StubKnowledgePort port = new([Card("a")]); - - AIContextProvider provider = Provider( - port, Resolved(KnowledgeMode.Prefetch, scoped: false)); - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - Assert.Equal(1, port.Calls); - Assert.Contains("card a", Merged(context), StringComparison.Ordinal); - } - - [Fact] - public async Task Create_UnscopedAgentUnderAScopedHostAmbient_StillSearchesTheWholeCorpus() - { - // Ruling 20, and the defect it resolves. example.yaml ships a mixed deployment: the resolver - // requires a scope, so the host sets ct900 for the whole conversation, and the analyst -- which the - // same document says "searches every product on purpose" -- was silently filtered to ct900 - // with it. The store folds whatever scope it is handed into the filter, so the turn's scope - // has to stop here for an unscoped agent. - ScopeFilteringKnowledgePort port = new( - (Card("ct900"), Facets("ct900")), - (Card("ent"), Facets("ct900ent"))); - - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: false)); - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - // The card OUTSIDE the host's facet is the whole point: an unscoped agent sees it. - Assert.Contains("card ent", Merged(context), StringComparison.Ordinal); - Assert.Contains("card ct900", Merged(context), StringComparison.Ordinal); - } - - [Fact] - public async Task Create_UnscopedAgent_ReachesTheStoreUnderAnEmptyScopeRatherThanTheHosts() - { - // The mechanism behind the fact above, asserted where the store reads it. An empty facet map - // adds no filter condition, so this is a whole-corpus read and not a differently-shaped one. - StubKnowledgePort port = new([Card("a")]); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: false)); - _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - Assert.NotNull(port.ScopeAtTheStore); - Assert.Empty(port.ScopeAtTheStore.Facets); - } - - [Fact] - public async Task Create_ScopedAgent_ReachesTheStoreUnderTheHostsOwnScope() - { - // The counterpart. Taking the scope away from the unscoped agent must not take it away from - // the scoped one, which is the agent the scope exists for. - StubKnowledgePort port = new([Card("a")]); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: true)); - _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - Assert.Same(scope, port.ScopeAtTheStore); - } - - [Fact] - public async Task Create_UnscopedAgent_PutsTheHostsScopeBackAfterTheSearch() - { - // The empty scope covers one port conversation and nothing else. Leaking it would silently unscope the - // scoped agent that runs next -- the very leak this whole design fails closed against, - // arriving from the inside. - StubKnowledgePort port = new([Card("a")]); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: false)); - _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - // The empty scope covered that one search only: a scoped turn still resolves the host's scope. - AIContextProvider scoped = Provider(port, Resolved(KnowledgeMode.Prefetch, scoped: true)); - _ = await InvokePrefetchAsync(scoped, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - Assert.Same(scope, port.ScopeAtTheStore); - } - - [Fact] - public async Task Create_ASearchThatAnswered_WritesTheRetrievalRecord() - { - // A19. Ruling 21 gave KnowledgeAuditRecord a producer: without one the type shipped tested and - // dead, and a retrieval left no artifact of any kind. - RecordingLoggerFactory loggers = new(); - StubKnowledgePort port = new([Card("a"), Linked("z")]); - - AIContextProvider provider = KnowledgeProviderFactory.Create( - port, Resolved(KnowledgeMode.Prefetch), "analyst", new SourceLocatorCitationFormatter(), loggers); - _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - CapturedLine line = Assert.Single(loggers.Of(11)); - Assert.Equal("analyst", line.Field("Agent")); - Assert.Equal(2, line.Field("CardCount")); - - KnowledgeAuditRecord.LogView? record = line.Field("Record"); - Assert.NotNull(record); - // "link", not "see_also": the audit record says how the card arrived, in its own words, - // rather than repeating one collection's payload key back at the reader. - Assert.Equal(["ranked", "link"], record.Cards.Select(card => card.Via)); - Assert.Equal("analyst", record.Agent); - } - - [Fact] - public async Task Create_ASearchThatThrew_WritesTheCauseTheModelNeverSees() - { - // In tool mode the framework replaces the message with "Error: Function failed.", and in - // prefetch mode this delegate answers a notice rather than throwing. Neither channel carries - // the cause, so an outage during a live conversation turns on this one line existing. - RecordingLoggerFactory loggers = new(); - InvalidOperationException down = new("qdrant is down"); - - AIContextProvider provider = KnowledgeProviderFactory.Create( - new ThrowingKnowledgePort(down), Resolved(KnowledgeMode.Prefetch), "resolver", new SourceLocatorCitationFormatter(), loggers); - _ = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - CapturedLine line = Assert.Single(loggers.Of(12)); - Assert.Equal(LogLevel.Error, line.Level); - Assert.Equal("resolver", line.Field("Agent")); - - // The cause travels as the log's own exception argument, which is where a structured sink - // stores a stack trace. Ruling 22 took the duplicate copy off the record's log view rather - // than write the same trace into a message field as well. - Assert.Same(down, line.Exception); - Assert.Contains("qdrant is down", line.Exception!.ToString(), StringComparison.Ordinal); - - KnowledgeAuditRecord.LogView? record = line.Field("Record"); - Assert.NotNull(record); - Assert.Equal("resolver", record.Agent); - } - - [Fact] - public async Task Create_NeitherLoggedRow_CarriesWhatTheCallerSaid() - { - // Ruling 22, and a regression this branch introduced. Query is the framework-composed search - // input: the caller's current utterance plus, at RecentMessageMemoryLimit = 4, up to four - // earlier messages. The failure row is an Error, which a default production configuration - // keeps on, so logging the record whole copied every caller's words into a log store once per - // agent per turn for as long as an outage lasted -- the exact thing Log.PromptRefused and - // Log.ReplyTruncated refuse a few lines apart in the same file. - const string spoken = "my ct900 shows e33 and my name is jane quimby on account 40771"; - - RecordingLoggerFactory loggers = new(); - - AIContextProvider answered = KnowledgeProviderFactory.Create( - new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch), "analyst", new SourceLocatorCitationFormatter(), loggers); - _ = await InvokePrefetchAsync(answered, spoken, PrefetchTurn(), new StubSession()); - - AIContextProvider threw = KnowledgeProviderFactory.Create( - new ThrowingKnowledgePort(new InvalidOperationException("qdrant is down")), - Resolved(KnowledgeMode.Prefetch), - "resolver", - new SourceLocatorCitationFormatter(), - loggers); - _ = await InvokePrefetchAsync(threw, spoken, PrefetchTurn(), new StubSession()); - - // Both rows were written -- a test that logged nothing would pass the assertions below - // vacuously, and this is exactly the fact that must not pass vacuously. - _ = Assert.Single(loggers.Of(11)); - _ = Assert.Single(loggers.Of(12)); - - foreach (CapturedLine line in loggers.Lines) - { - // The formatted text AND every structured field: a sink reads the fields, not the string. - Assert.DoesNotContain(spoken, line.Message, StringComparison.Ordinal); - Assert.DoesNotContain(spoken, line.Exception?.ToString() ?? string.Empty, StringComparison.Ordinal); - - foreach (KeyValuePair field in line.Fields) - { - Assert.DoesNotContain( - spoken, field.Value?.ToString() ?? string.Empty, StringComparison.Ordinal); - } - } - } - - [Fact] - public async Task Create_TheLoggedRow_CarriesTheQuerysLengthInstead() - { - // What replaces the text, and why a length rather than a hash: an outage is diagnosed by - // whether the input was well formed. A zero-length query is a provider bug and a runaway one - // is the recent-message concatenation gone wrong; a length answers both, and a hash answers - // only "the same text again" while still being a per-caller correlation handle. - const string spoken = "the screen says e33"; - - RecordingLoggerFactory loggers = new(); - - AIContextProvider provider = KnowledgeProviderFactory.Create( - new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch), "analyst", new SourceLocatorCitationFormatter(), loggers); - _ = await InvokePrefetchAsync(provider, spoken, PrefetchTurn(), new StubSession()); - - KnowledgeAuditRecord.LogView? view = Assert.Single(loggers.Of(11)).Field("Record"); - Assert.NotNull(view); - Assert.Equal(spoken.Length, view.QueryLength); - Assert.Equal("analyst", view.Agent); - Assert.Equal(KnowledgeMode.Prefetch, view.Mode); - } - - [Theory] - [InlineData(2, "card b", "card c")] - [InlineData(3, "card c", "card d")] - public async Task Create_MoreCardsThanTheAgentAsksFor_KeepsTheBestFew( - int limit, string lastKept, string firstDropped) - { - // Ruling 14(c). The store fetches once, up to its own deployment ceiling, for every agent. - // limit: is this agent's view of that fetch, and the port returns cards best first. - // Two limits, because one cannot tell the agent's limit from a hardcoded number. - StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Card("d"), Card("e")]); - - AIContextProvider provider = Provider( - port, Resolved(KnowledgeMode.Prefetch, limit: limit)); - AIContext context = await InvokePrefetchAsync( - provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - string text = Merged(context); - Assert.Contains("card a", text, StringComparison.Ordinal); - Assert.Contains(lastKept, text, StringComparison.Ordinal); - Assert.DoesNotContain(firstDropped, text, StringComparison.Ordinal); - Assert.DoesNotContain("card e", text, StringComparison.Ordinal); - } - - [Fact] - public async Task Create_TheLimit_CountsRankedCardsAndLetsALinkedOneRideAlong() - { - // Ruling 16. A7 makes see_also expansion never optional, and the store appends the linked - // cards after every scored one. A plain prefix would drop every link whenever the fetch - // filled up -- which, at the shipped defaults of 5 and 5, is every full result. The winning - // probe arm was "top 5 plus see_also of the top hit": the links are additional to the five. - StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Linked("z")]); - - AIContextProvider provider = Provider( - port, Resolved(KnowledgeMode.Prefetch, limit: 2)); - AIContext context = await InvokePrefetchAsync( - provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - string text = Merged(context); - Assert.Contains("card a", text, StringComparison.Ordinal); - Assert.Contains("card b", text, StringComparison.Ordinal); - Assert.DoesNotContain("card c", text, StringComparison.Ordinal); - Assert.Contains("card z", text, StringComparison.Ordinal); - } - - [Fact] - public async Task Create_CitationsOff_AsksForNoCitationAtAll() - { - // A null CitationsPrompt is not "off": it produces the framework's own default block, - // which asks for a document name and link, and there is never a link. - AIContextProvider provider = Provider( - new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch, citations: false)); - - AIContext context = await InvokePrefetchAsync( - provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - string text = Merged(context); - Assert.DoesNotContain("Include citations", text, StringComparison.Ordinal); - Assert.DoesNotContain("ct900-om", text, StringComparison.Ordinal); - } - - [Fact] - public async Task Create_CitationsOn_NamesTheSourceAndForbidsAnInventedLink() - { - AIContextProvider provider = Provider( - new StubKnowledgePort([Card("a")]), Resolved(KnowledgeMode.Prefetch, citations: true)); - - AIContext context = await InvokePrefetchAsync( - provider, "the screen says e33", PrefetchTurn(), new StubSession()); - - string text = Merged(context); - Assert.Contains("ct900-om, p.27", text, StringComparison.Ordinal); - Assert.DoesNotContain("Include citations", text, StringComparison.Ordinal); - Assert.Contains("Do not invent a link", text, StringComparison.Ordinal); - } - - [Fact] - public async Task Create_CitationsOn_PublishesASourceForEachCardShown() - { - // Task 5 fix round 1, Finding 1. Nothing in this suite drove the sources port to a - // non-null value for a citing search before this test, so the body of the publish loop had - // never executed. A collector plus an outer call, both open around the same InvokingAsync - // the other tests already drive, is what proves the wiring rather than just reading it. - StubKnowledgePort port = new([Card("a"), Card("b")]); - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, citations: true)); - - TurnSources sources = new(); - - StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn(sources: sources)); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - } - - IReadOnlyList cited = sources.TakeFor("conversation-1"); - Assert.Equal(2, cited.Count); - Assert.Equal( - ["a", "b"], cited.Select(content => content.Source.SourceId).OrderBy(id => id)); - Assert.All(cited, content => Assert.Equal("ct900-om, p.27", content.Source.Title)); - } - - [Fact] - public async Task Create_CitationsOff_PublishesNothing() - { - // The leak test, and the most important one in this task. citations: false is the single - // switch a deployment uses to decide whether it discloses its sources at all -- a chip on - // screen is a disclosure just as much as the label the model is shown -- so this must - // publish nothing even with a collector and an outer call both open and cards coming back. - StubKnowledgePort port = new([Card("a"), Card("b")]); - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, citations: false)); - - TurnSources sources = new(); - - StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn(sources: sources)); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - } - - Assert.Empty(sources.TakeFor("conversation-1")); - } - - [Fact] - public async Task Create_MoreCardsThanTheLimit_CitesOnlyTheCardsTheModelIsShown() - { - // Finding 2. A card ranked past the agent's limit: is never shown to the model, so citing - // it anyway would put a chip on screen for a document the answer could not possibly have - // drawn on. The cited set must match the kept set, not the store's full return. - StubKnowledgePort port = new([Card("a"), Card("b"), Card("c"), Card("d"), Card("e")]); - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch, limit: 2, citations: true)); - - TurnSources sources = new(); - - StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn(sources: sources)); - using (sources.BeginOuterCall("conversation-1")) - { - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - } - - IReadOnlyList cited = sources.TakeFor("conversation-1"); - Assert.Equal(2, cited.Count); - Assert.Equal( - ["a", "b"], cited.Select(content => content.Source.SourceId).OrderBy(id => id)); - } - - [Fact] - public async Task Create_WhatAnEarlierProviderInjected_IsNotPartOfTheQuery() - { - // The knowledge provider is bound second, after TurnContextProvider, and the framework - // hands each provider what the one before it produced. The turn's own instructions are not - // a query, and a search over them retrieves the wrong cards. - StubKnowledgePort port = new([]); - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch)); - StubSession session = new(); - TurnRegistry.Set(session, PrefetchTurn()); - -#pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. - AIContextProvider.InvokingContext context = new( - StubAgent.Instance, - session, - new AIContext - { - Messages = - [ - new ChatMessage(ChatRole.User, "the screen says e33"), - new ChatMessage(ChatRole.System, "ask the caller for the machine model") - .WithAgentRequestMessageSource( - AgentRequestMessageSourceType.AIContextProvider, "TurnContextProvider"), - ], - }); -#pragma warning restore MAAI001 - - _ = await provider.InvokingAsync(context, TestContext.Current.CancellationToken); - - Assert.Equal("the screen says e33", port.LastQuery); - } - - [Fact] - public async Task Create_ASecondTurn_SearchesWithWhatTheCallerSaidBefore() - { - // RecentMessageMemoryLimit defaults to 0, and at 0 turn two's query is the bare new - // message: "does it need a part?" resolves its pronoun against nothing. - StubKnowledgePort port = new([Card("a")]); - StubSession session = new(); - - AIContextProvider provider = Provider(port, Resolved(KnowledgeMode.Prefetch)); - TurnRegistry.Set(session, PrefetchTurn()); - _ = await provider.InvokingAsync( - Invoking("the screen says e33", session), TestContext.Current.CancellationToken); - await provider.InvokedAsync( - Invoked("the screen says e33", session), TestContext.Current.CancellationToken); - _ = await provider.InvokingAsync( - Invoking("does it need a part?", session), TestContext.Current.CancellationToken); - - Assert.Contains("the screen says e33", port.LastQuery!, StringComparison.Ordinal); - Assert.Contains("does it need a part?", port.LastQuery!, StringComparison.Ordinal); - } - - [Fact] - public async Task Search_ToolModeWithNoResults_InjectsTheEmptyNotice() - { - AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Tool, scoped: true)); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - IReadOnlyList results = await InvokeSearchAsync(provider, "f63 error e03", ToolTurn(scope)); - - Assert.Contains(results, r => r.Text.Contains("holds nothing", StringComparison.Ordinal)); - } - - [Fact] - public async Task Search_PrefetchModeWithNoResults_InjectsNothing() - { - AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Prefetch)); - - AIContext context = await InvokePrefetchAsync(provider, "hello", PrefetchTurn(), new StubSession()); - - Assert.Equal(["hello"], Texts(context)); - } - - [Fact] - public async Task Search_ScopedPrefetchModeWithNoResults_InjectsNothing() - { - // Both halves of the notice guard matter. A scoped agent with a real facet open already - // clears the Facets.Count > 0 half, so only the KnowledgeMode.Tool clause keeps a prefetch - // agent's empty search silent. - AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Prefetch, scoped: true)); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - AIContext context = await InvokePrefetchAsync(provider, "the screen says e33", PrefetchTurn(scope), new StubSession()); - - Assert.Equal(["the screen says e33"], Texts(context)); - } - - [Fact] - public async Task Search_UnscopedToolModeWithNoResults_InjectsNothing() - { - // scoped: false opens WholeCorpus, which holds no facets. Such an agent's empty search stays - // byte-identical to an unscoped one: an empty list, no notice. - AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Tool, scoped: false)); - - IReadOnlyList results = await InvokeSearchAsync(provider, "f63 error e03", ToolTurn()); - - Assert.Empty(results); - } - - [Fact] - public async Task Search_EveryNotice_CarriesTheReservedSourceName() - { - // A notice must never be citable as a card, and the reserved source name is what stops it. - AIContextProvider provider = Provider(new StubKnowledgePort([]), Resolved(KnowledgeMode.Tool, scoped: true)); - KnowledgeScope scope = new() { Facets = new Dictionary { ["model"] = "ct900" } }; - - IReadOnlyList results = await InvokeSearchAsync(provider, "f63 error e03", ToolTurn(scope)); - - Assert.All(results, r => Assert.Equal("agentcore:notice", r.SourceName)); - } - - [Fact] - public async Task Search_ToolModeThatThrows_StillSaysUnreachable() - { - AIContextProvider provider = Provider( - new ThrowingKnowledgePort(new InvalidOperationException("boom")), Resolved(KnowledgeMode.Tool)); - - IReadOnlyList results = await InvokeSearchAsync(provider, "f63", ToolTurn()); - - Assert.Contains(results, r => r.Text.Contains("unreachable", StringComparison.Ordinal)); - } - - /// Builds the provider under test, with the agent id and logger these facts do not read. - /// The store the provider searches. - /// The agent's resolved knowledge: block. - /// The provider. - private static TurnInvocation PrefetchTurn(KnowledgeScope? scope = null, TurnSources? sources = null) - { - return new() - { - ConversationId = "conversation", - TurnIndex = 0, - Stage = "", - Knowledge = scope, - Sources = sources, - }; - } - - private static TurnInvocation ToolTurn( - KnowledgeScope? scope = null, Clarifications? clarifications = null, TurnSources? sources = null) - { - return new() - { - ConversationId = "conversation", - TurnIndex = 0, - Stage = "", - Knowledge = scope, - Clarifications = clarifications ?? new Clarifications(), - Sources = sources, - }; - } - - private static async Task InvokePrefetchAsync( - AIContextProvider provider, string text, TurnInvocation turn, AgentSession session) - { - TurnRegistry.Set(session, turn); - return await provider.InvokingAsync( - Invoking(text, session), TestContext.Current.CancellationToken).ConfigureAwait(false); - } - - private static AIContextProvider Provider(IKnowledgeRetrievalPort port, ResolvedKnowledge knowledge) - { - return KnowledgeProviderFactory.Create( - port, knowledge, "agent-under-test", new SourceLocatorCitationFormatter(), loggers: null); - } - - /// - /// Invokes the search tool the provider offers, the way the model would: the turn rides along - /// as an argument, carrying the scope and the clarifications the search runs under. - /// - /// The provider built. - /// The search text. - /// The turn the search runs under. - /// What the search returned. - private static async Task> InvokeSearchAsync( - AIContextProvider provider, string query, TurnInvocation turn) - { - StubSession session = new(); - AIContext context = await provider.InvokingAsync( - Invoking("hello", session), TestContext.Current.CancellationToken).ConfigureAwait(false); - AITool search = Assert.Single(context.Tools!, tool => tool.Name == "Search"); - IReadOnlyList? results = await ((AIFunction)search).InvokeAsync( - turn.FileIn(new AIFunctionArguments(new Dictionary(StringComparer.Ordinal) - { - ["userQuestion"] = query, - })), - TestContext.Current.CancellationToken).ConfigureAwait(false) - as IReadOnlyList; - return results!; - } - - /// Every message text of a returned context, in one string. - private static string Merged(AIContext context) - { - return string.Join('\n', Texts(context)); - } - - private static IEnumerable Texts(AIContext context) - { - return (context.Messages ?? []).Select(message => message.Text); - } - - private static ResolvedKnowledge Resolved( - KnowledgeMode mode, int limit = 5, bool citations = false, bool scoped = false) - { - return new(mode, limit, citations, scoped); - } - - /// The facet map one card of the scope-filtering corpus carries. - private static Dictionary Facets(string model) - { - return new(StringComparer.Ordinal) { ["model"] = model }; - } - - private static KnowledgeCard Card(string id) - { - return new() - { - CardId = id, - Text = "card " + id, - Authority = 3, - SourceRef = "ct900-om", - SourceLocator = "p.27", - Score = 0.87, - ViaLink = false, - }; - } - - /// A card see_also pulled in. The store appends these after every scored card. - private static KnowledgeCard Linked(string id) - { - return Card(id) with { Score = null, ViaLink = true }; - } - - /// Runs the provider the way the framework runs it, over one caller message. - private static AIContextProvider.InvokingContext Invoking(string text, AgentSession session) - { -#pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. - return new( - StubAgent.Instance, - session, - new AIContext { Messages = [new ChatMessage(ChatRole.User, text)] }); -#pragma warning restore MAAI001 - } - - /// Closes a turn, so the provider stores what it should remember of it. - private static AIContextProvider.InvokedContext Invoked(string text, AgentSession session) - { -#pragma warning disable MAAI001 // The context constructors are the framework's own experimental surface. - return new( - StubAgent.Instance, - session, - [new ChatMessage(ChatRole.User, text)], - [new ChatMessage(ChatRole.Assistant, "let me look")]); -#pragma warning restore MAAI001 - } - - private sealed class StubSession : AgentSession; - - /// Stands in for the agent the framework names on a context. Nothing here runs it. - private sealed class StubAgent : AIAgent - { - public static StubAgent Instance { get; } = new(); - - protected override ValueTask CreateSessionCoreAsync( - CancellationToken cancellationToken = default) - { - return new(new StubSession()); - } - - protected override ValueTask SerializeSessionCoreAsync( - AgentSession session, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - - protected override ValueTask DeserializeSessionCoreAsync( - JsonElement serializedState, - JsonSerializerOptions? jsonSerializerOptions = null, - CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - - protected override Task RunCoreAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - - protected override IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, - AgentSession? session = null, - AgentRunOptions? options = null, - CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - } - } -} diff --git a/tests/AgentCore.Application.Tests/Ports/.editorconfig b/tests/AgentCore.Application.Tests/Ports/.editorconfig deleted file mode 100644 index 3d6eebe7..00000000 --- a/tests/AgentCore.Application.Tests/Ports/.editorconfig +++ /dev/null @@ -1,5 +0,0 @@ -[*.cs] - -# CA1707: "Remove the underscores from member name". A test name reads Subject_Behaviour, and the -# underscore is what separates the two halves. -dotnet_diagnostic.CA1707.severity = none diff --git a/tests/AgentCore.Application.Tests/Ports/VendorAdapterTests.cs b/tests/AgentCore.Application.Tests/Ports/VendorAdapterTests.cs deleted file mode 100644 index 286412af..00000000 --- a/tests/AgentCore.Application.Tests/Ports/VendorAdapterTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Reflection; -using AgentCore.Application.Ports; -using Xunit; - -namespace AgentCore.Application.Tests.Ports -{ - /// Pins that every vendor seam shares one base, so one selector can serve them all. - public sealed class VendorAdapterTests - { - [Theory] - [InlineData(typeof(IChatClientAdapter))] - [InlineData(typeof(IKnowledgeStoreAdapter))] - [InlineData(typeof(IModerationAdapter))] - [InlineData(typeof(ITelemetryAdapter))] - [InlineData(typeof(ISpeechAdapter))] - [InlineData(typeof(IConversationAdapter))] - public void EveryVendorSeamDerivesFromTheOneBase(Type seam) - { - Assert.True( - typeof(IVendorAdapter).IsAssignableFrom(seam), - $"{seam.Name} must derive from IVendorAdapter so VendorAdapterSelector can serve it."); - } - - [Fact] - public void TheBaseCarriesOnlyTheKind() - { - MemberInfo[] members = typeof(IVendorAdapter).GetMembers(); - - // One property means one getter method plus the property itself. - Assert.Equal(2, members.Length); - Assert.NotNull(typeof(IVendorAdapter).GetProperty("Kind")); - } - } -} diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs index 9ce1eee8..2d34a7ff 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentSessionTests.cs @@ -3,6 +3,7 @@ using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Runtime; using AgentCore.Application.Transcript; +using AgentCore.TestSupport; using Microsoft.Extensions.AI; using System.Text.Json; using System.Text.Json.Nodes; @@ -45,7 +46,7 @@ public async Task SerializeSessionAsync_WritesTheStateTheConversationHolds() public async Task SerializeSessionAsync_NamesTheConversationTheStateBelongsTo() { AgentCoreAgent agent = BuildAgent(new SequencedChatClient("a reply"), out _); - AgentSession session = await agent.CreateSessionAsync("conversation-42"); + AgentSession session = await agent.CreateSessionAsync("conversation-42", TestContext.Current.CancellationToken); JsonElement serialized = await agent.SerializeSessionAsync( session, cancellationToken: TestContext.Current.CancellationToken); @@ -60,19 +61,38 @@ public async Task SerializeSessionAsync_NoSession_IsRefusedRatherThanAnsweredWit { AgentCoreAgent agent = BuildAgent(new SequencedChatClient("a reply"), out _); - // A run answers a null session by making one — the framework's one-shot shape, a conversation nothing - // continues. There is no one-shot serialize: the envelope would name a fresh random id beside - // an empty state, and a host would keep that as its checkpoint and never learn it points at - // nothing. + // A run with no session ends its conversation with the run, so there is nothing to serialize: the + // envelope would name a fresh random id beside an empty state, and a host would keep that as its + // checkpoint and never learn it points at nothing. _ = await Assert.ThrowsAsync(async () => await agent.SerializeSessionAsync(null!, cancellationToken: TestContext.Current.CancellationToken)); } + [Fact] + public async Task SerializeSessionAsync_AfterItsConversationHasUnloaded_WritesTheIdWithNoState() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + AgentCoreAgent agent = BuildAgent(new SequencedChatClient("a reply"), out _, timeProvider: clock, idleTimeout: TimeSpan.FromMinutes(1)); + + AgentSession session = await agent.CreateSessionAsync("conversation-42", TestContext.Current.CancellationToken); + _ = await agent.RunAsync("hello", session, cancellationToken: TestContext.Current.CancellationToken); + + clock.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1)); + + JsonElement serialized = await agent.SerializeSessionAsync( + session, cancellationToken: TestContext.Current.CancellationToken); + + // Store 0's own copy of the state outranks whatever this session held before it unloaded, so + // the blob carries only the id: a live session's snapshot would be stale the moment it wrote. + Assert.Equal("conversation-42", serialized.GetProperty("conversationId").GetString()); + Assert.Equal(JsonValueKind.Null, serialized.GetProperty("state").ValueKind); + } + [Fact] public async Task ARoundTrip_ComesBackOnTheSameConversation() { AgentCoreAgent agent = BuildAgent(new SequencedChatClient("a reply", "another reply"), out _); - AgentSession session = await agent.CreateSessionAsync("conversation-42"); + AgentSession session = await agent.CreateSessionAsync("conversation-42", TestContext.Current.CancellationToken); _ = await agent.RunAsync("hello", session, cancellationToken: TestContext.Current.CancellationToken); JsonElement serialized = await agent.SerializeSessionAsync( @@ -327,7 +347,7 @@ public async Task DeserializeSessionAsync_WithStateButNoConversationId_StartsANe public async Task ARoundTripOfATerminalConversation_ComesBackTerminalAndRefusesItsTurn() { AgentCoreAgent agent = BuildAgent(new SequencedChatClient("a reply"), out _, TerminalAgentYaml); - AgentSession session = await agent.CreateSessionAsync("conversation-42"); + AgentSession session = await agent.CreateSessionAsync("conversation-42", TestContext.Current.CancellationToken); _ = await agent.RunAsync("hello", session, cancellationToken: TestContext.Current.CancellationToken); Assert.True(session.GetService()!.IsComplete); diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs index 7d3c2c46..8b4f6216 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTestSupport.cs @@ -4,6 +4,7 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; using AgentCore.TestSupport; using Microsoft.Extensions.AI; using System.Text.Json; @@ -72,14 +73,23 @@ internal static AgentCoreAgent BuildAgent( out CompiledAgent compiled, string yaml = SingleAgentYaml, IConversationStore? store = null, - string entryName = "main") + string entryName = "main", + IEnumerable? observers = null, + TimeProvider? timeProvider = null, + TimeSpan? idleTimeout = null) { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - compiled = ConfigurationCompiler.CompileAll( + IReadOnlyDictionary entries = ConfigurationCompiler.CompileAll( document, - new AgentCompilationContext(new RoutingChatClientFactory(reply)) { ConversationStore = store })[entryName]; + new AgentCompilationContext(new RoutingChatClientFactory(reply)) { ConversationStore = store }); + compiled = entries[entryName]; + + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards), observers: observers); + InMemoryConversationSessions sessions = new( + new Dictionary(StringComparer.Ordinal) { [entryName] = factory }, + idleTimeout ?? TimeSpan.FromMinutes(30), + timeProvider ?? TimeProvider.System); - ConversationSessionFactory sessions = new(compiled, new GuardEvaluator(compiled.Configuration.Guards)); return new AgentCoreAgent(sessions, entryName); } diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs index 8b6a95ce..3bc7c369 100644 --- a/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/AgentCoreAgentTests.cs @@ -1,5 +1,8 @@ +using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Runtime; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; @@ -93,17 +96,51 @@ [new ChatMessage(ChatRole.Assistant, "not a prompt")], } [Fact] - public async Task RunAsync_WithNoSession_RunsOneShotConversations() + public async Task RunAsync_WithNoSession_RunsANormalTurnAndLeavesTheConversationOpen() { SequencedChatClient reply = new("one", "two"); - AgentCoreAgent agent = BuildAgent(reply, out _); + InMemoryAuditSink sink = new(); + AgentCoreAgent agent = BuildAgent(reply, out _, observers: ConversationObservers.Standard(sink, logger: null)); + + AgentResponse first = await agent.RunAsync("first", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("one", first.Text); + AuditEvent started = Assert.Single(sink.Events, e => e.Kind == AuditEventKind.ConversationStarted); + Assert.DoesNotContain(sink.Events, e => e.Kind == AuditEventKind.ConversationEnded); + + // A caller that later names the minted id explicitly (the only way MAF lets one recover it) finds + // the same conversation still open, with the first turn's exchange in front of the second. + AgentSession resumed = await agent.CreateSessionAsync(started.ConversationId, TestContext.Current.CancellationToken); + AgentResponse second = await agent.RunAsync("second", resumed, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("two", second.Text); + Assert.Equal("second", reply.LastUserText(1)); + Assert.Contains(reply.Requests[1], message => message.Role == ChatRole.User && message.Text == "first"); + Assert.Contains(reply.Requests[1], message => message.Role == ChatRole.Assistant && message.Text == "one"); + } + + [Fact] + public async Task RunAsync_OnAHeldSessionAfterItsConversationUnloaded_StillRuns() + { + SequencedChatClient reply = new("first reply", "second reply"); + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + AgentCoreAgent agent = BuildAgent(reply, out _, timeProvider: clock, idleTimeout: TimeSpan.FromMinutes(1)); + + AgentSession session = await agent.CreateSessionAsync(TestContext.Current.CancellationToken); + AgentResponse first = await agent.RunAsync("hello", session, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("first reply", first.Text); - _ = await agent.RunAsync("first", cancellationToken: TestContext.Current.CancellationToken); - _ = await agent.RunAsync("second", cancellationToken: TestContext.Current.CancellationToken); + // Past the idle timeout: the owner has unloaded the conversation this session names, but the + // caller still holds the MAF AgentSession object from before that happened. + clock.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(1)); + + AgentResponse second = await agent.RunAsync("and again", session, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("second reply", second.Text); - // No session, no continuity: the second run is a new conversation and saw nothing of the first. - Assert.DoesNotContain(reply.Requests[1], message => message.Text == "first"); - Assert.DoesNotContain(reply.Requests[1], message => message.Text == "one"); + // History survived the unload: the rebuilt session re-read the transcript store 0 still holds. + Assert.Contains(reply.Requests[1], message => message.Role == ChatRole.User && message.Text == "hello"); + Assert.Contains(reply.Requests[1], message => message.Role == ChatRole.Assistant && message.Text == "first reply"); } [Fact] @@ -126,7 +163,7 @@ public async Task CreateSessionAsync_WithAConversationId_NamesTheConversation() { AgentCoreAgent agent = BuildAgent(new SequencedChatClient("unused"), out _); - AgentSession session = await agent.CreateSessionAsync("conversation-42"); + AgentSession session = await agent.CreateSessionAsync("conversation-42", TestContext.Current.CancellationToken); Assert.Equal("conversation-42", session.GetService()?.ConversationId); } diff --git a/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs b/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs new file mode 100644 index 00000000..ae8e0878 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/AgentDelegationBackgroundReleaseTests.cs @@ -0,0 +1,95 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Bug 7 of the 2026-09-24 round: DelegatedAgentRun made a fresh session per kind: agent + /// call and never released it, so a background: child the delegate started outlived the + /// conversation. The delegate's own ChatOptions attaches its + /// directly, so the fresh session's only owner is the delegation call itself — it must release it when + /// the tool returns, on the happy path and on a fault. + /// + public sealed class AgentDelegationBackgroundReleaseTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: ask_helper, kind: agent, agent: helper, description: "ask the helper" } + agents: + items: + - { id: first, instructions: "ask", tools: [ ask_helper ], model: { ref: first } } + - { id: helper, instructions: "delegate work", model: { ref: helper }, background: [ blocker ] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + agent: first + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Dictionary StartArgs() => new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }; + + [Fact] + public async Task Delegation_ToAnAgentWithABackgroundChild_ReleasesItWhenTheToolReturns() + { + using HangUntilCancelledChatClient blocker = new(); + ToolCallingChatClient first = new("first done", new Dictionary(StringComparer.Ordinal) { ["query"] = "go" }); + ToolCallingChatClient helper = new("helper done", StartArgs()); + RoutingChatClientFactory clients = new(first); + _ = clients.Route("first", first); + _ = clients.Route("helper", helper); + _ = clients.Route("blocker", blocker); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(Yaml), + new AgentCompilationContext(clients) { ConversationStore = new InMemoryConversationStore() })["main"]; + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create("c1"); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + + Assert.Equal("first done", turn.ReplyText); + await blocker.Started.WaitAsync(TimeSpan.FromSeconds(10), Ct); + await blocker.Cancelled.WaitAsync(TimeSpan.FromSeconds(10), Ct); + } + + /// + /// Claim (R4-5): a delegation whose fresh session started a background child that ignores its cancel + /// must not stall the outer turn for 's timeout. Before + /// the fix, the delegation's finally awaited the release with + /// inside the tool call, so the outer model never saw the tool result until the release gave up. + /// + [Fact] + public async Task Delegation_ToAnAgentWithADeafChild_DoesNotStallTheTurn() + { + using DeafChatClient blocker = new(); + ToolCallingChatClient first = new("first done", new Dictionary(StringComparer.Ordinal) { ["query"] = "go" }); + ToolCallingChatClient helper = new("helper done", StartArgs()); + RoutingChatClientFactory clients = new(first); + _ = clients.Route("first", first); + _ = clients.Route("helper", helper); + _ = clients.Route("blocker", blocker); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(Yaml), + new AgentCompilationContext(clients) { ConversationStore = new InMemoryConversationStore() })["main"]; + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create("c1"); + + TurnResult turn = await session.RunTurnAsync("go", Ct).WaitAsync(TimeSpan.FromSeconds(2), Ct); + + Assert.Equal("first done", turn.ReplyText); + blocker.Release(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/CallForever.cs b/tests/AgentCore.Application.Tests/Runtime/CallForever.cs new file mode 100644 index 00000000..da0998ef --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/CallForever.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// A model that calls the one tool it is given, every round, and never stops on its own. + internal sealed class CallForever : IChatClient + { + private int _round; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + int round = Interlocked.Increment(ref _round); + string id = Guid.NewGuid().ToString("N"); + if (options?.Tools?.OfType().FirstOrDefault() is not { } tool) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "no tool") { ResponseId = id, MessageId = id }; + yield break; + } + + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new TextContent($"Checking {round}. "), new FunctionCallContent($"call_{round}", tool.Name, new Dictionary())]) + { + ResponseId = id, + MessageId = id, + }; + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs new file mode 100644 index 00000000..8b54325b --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationBusyMarkTests.cs @@ -0,0 +1,144 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The busy mark a turn puts on its conversation in the store (owner ruling for soak F1, after LangGraph's thread + /// busy status and its enqueue strategy): a turn of another session waits for it, then reads the + /// conversation afresh. + /// + public sealed class ConversationBusyMarkTests + { + private const string ConversationId = "c-busy"; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 30_000)] + public async Task ATurnOfAnotherSessionStillSaving_HoldsTheNextTurn_WhichThenTakesTheTurnAfterIt() + { + // Arrange: the first session's words are still on their way to the store when the second one starts. + ParkingConversationStore parking = new(); + MarkWatchingStore store = new(parking); + RequestRecordingChatClient model = new("first reply", "second reply"); + ConversationSessionFactory factory = Build(model, store, TimeProvider.System, new InMemoryAuditSink(), logger: null); + ConversationSession first = factory.Create(ConversationId); + ConversationSession second = factory.Create(ConversationId); + + Task running = first.RunTurnAsync("first", Ct); + await parking.Parked.WaitAsync(Ct); + + // Act + Task next = second.RunTurnAsync("second", Ct); + _ = await Task.WhenAny(store.MarkRefused, next); + parking.Release(); + TurnResult firstResult = await running; + TurnResult nextResult = await next; + + // Assert + Assert.Equal((0, 1), (firstResult.TurnIndex, nextResult.TurnIndex)); + IReadOnlyList rows = await store.ReadAllAsync(ConversationId, Ct); + Assert.Equal( + [(0, "first"), (0, "first reply"), (1, "second"), (1, "second reply")], + rows.Select(row => (row.TurnIndex, row.Content.Text))); + } + + [Fact(Timeout = 30_000)] + public async Task AMarkLeftByAHostThatCrashed_HoldsTheTurnUntilItsLeaseLapses_ThenTheTurnRuns() + { + // Arrange: a host put the mark and died, so nothing renews or clears it. + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + InMemoryConversationStore store = new(time); + RequestRecordingChatClient model = new("hello"); + ConversationSession session = Build(model, store, time, new InMemoryAuditSink(), logger: null).Create(ConversationId); + _ = await store.TryMarkBusyAsync(ConversationId, "crashed-host", ConversationBusyMark.Lease, Ct); + + // Act + Task turn = session.RunTurnAsync("hi", Ct); + _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.FirstPoll, 1), turn); + bool heldWhileLive = !turn.IsCompleted && model.Requests.Count == 0; + time.Advance(ConversationBusyMark.Lease); + TurnResult result = await turn; + + // Assert + Assert.True(heldWhileLive); + Assert.Equal((0, "hello", null), (result.TurnIndex, result.ReplyText, result.Failure)); + } + + [Fact(Timeout = 30_000)] + public async Task ACallerThatLeavesWhileTheTurnWaits_DropsTheTurn_WithAWarningAndATurnRefused() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + InMemoryConversationStore store = new(time); + RequestRecordingChatClient model = new("hello"); + InMemoryAuditSink sink = new(); + using RecordingLoggerFactory logs = new(); + ConversationSession session = Build(model, store, time, sink, logs.CreateLogger("session")).Create(ConversationId); + _ = await store.TryMarkBusyAsync(ConversationId, "other-host", TimeSpan.FromHours(1), Ct); + using CancellationTokenSource caller = CancellationTokenSource.CreateLinkedTokenSource(Ct); + + // Act + Task turn = session.RunTurnAsync("hi", caller.Token); + _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.FirstPoll, 1), turn); + await caller.CancelAsync(); + Exception? dropped = await Record.ExceptionAsync(() => turn); + + // Assert + _ = Assert.IsAssignableFrom(dropped); + Assert.Empty(model.Requests); + AuditEvent refused = Assert.Single(sink.EventsOf(ConversationId), item => item.Kind == AuditEventKind.TurnRefused); + Assert.Equal(("gone", null), (refused.Payload[AuditPayloadKeys.RefusedReason], refused.TurnIndex)); + CapturedLine line = Assert.Single(logs.Of(40)); + Assert.Equal((LogLevel.Warning, "gone"), (line.Level, line.Field("Reason"))); + } + + private static ConversationSessionFactory Build( + RequestRecordingChatClient model, IConversationStore store, TimeProvider time, InMemoryAuditSink sink, ILogger? logger) + { + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(ConversationSessionTestSupport.OneAgentYaml), + new AgentCompilationContext(new FakeChatClientFactory(model)) { ConversationStore = store })["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + extractor: null, + timeProvider: time, + logger: logger, + observers: ConversationObservers.Standard(sink, logger)); + } + + /// Reports the first time a turn found the conversation marked by another session. + private sealed class MarkWatchingStore(IConversationStore inner) : DelegatingConversationStore(inner) + { + private readonly TaskCompletionSource _refused = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task MarkRefused => _refused.Task; + + public override async ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + bool marked = await base.TryMarkBusyAsync(conversationId, holder, lease, cancellationToken); + if (!marked) + { + _ = _refused.TrySetResult(); + } + + return marked; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs index 003f0998..48efbd02 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationEventKindsTests.cs @@ -20,6 +20,7 @@ public sealed class ConversationEventKindsTests { ConversationEventKind.ReplyInterrupted, AuditEventKind.ReplyInterrupted, "reply.interrupted" }, { ConversationEventKind.ConversationEnded, AuditEventKind.ConversationEnded, "conversation.ended" }, { ConversationEventKind.TurnSuperseded, AuditEventKind.TurnSuperseded, "turn.superseded" }, + { ConversationEventKind.TurnRefused, AuditEventKind.TurnRefused, "turn.refused" }, }; /// Every diagnostic kind, beside the name a log line gives it. @@ -32,7 +33,8 @@ public sealed class ConversationEventKindsTests { ConversationEventKind.ExtractionFailed, "extraction.failed" }, { ConversationEventKind.TranscriptWriteFailed, "transcript.write.failed" }, { ConversationEventKind.StateRestorePartial, "state.restore.partial" }, - { ConversationEventKind.TranscriptResyncFailed, "transcript.resync.failed" }, + { ConversationEventKind.TranscriptResyncFailed, "transcript.resync.failed" }, + { ConversationEventKind.RunFaulted, "run.faulted" }, }; [Theory] @@ -63,8 +65,8 @@ public void EveryKind_IsEitherStoredOrDiagnostic_AndEveryOneIsCovered() int stored = Enum.GetValues() .Count(kind => ConversationEventKinds.TryGetAuditKind(kind, out _)); - Assert.Equal(14, Enum.GetValues().Length); - Assert.Equal(7, stored); + Assert.Equal(16, Enum.GetValues().Length); + Assert.Equal(8, stored); } [Fact] diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs new file mode 100644 index 00000000..316924c8 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionBargeInTests.cs @@ -0,0 +1,119 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Item 6a: barge-in. The record holds what the caller heard. + public sealed class ConversationSessionBargeInTests + { + [Fact] + public async Task AnInterruption_RecordsTheHeardTextAndTheDurationTheRelayReported() + { + using ScriptedChatClient reply = new("I can help with that,", " and here is the long part.") + { + GateAfterFirstFragment = true, + }; + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + timeout.Token, TestContext.Current.CancellationToken); + + await using IAsyncEnumerator updates = session.RunTurnStreamingAsync("hi", linked.Token).GetAsyncEnumerator(linked.Token); + Assert.True(await updates.MoveNextAsync()); + Assert.Equal("I can help with that,", updates.Current.Text); + + // Section 7.1 reports both values on the interrupt frame, at 1 ms. Nothing estimates either. + Assert.True(session.Cut(0, new TurnCut("I can help", TimeSpan.FromMilliseconds(740)))); + Assert.False(await updates.MoveNextAsync()); + + Assert.NotNull(session.LastTurn); + Assert.Equal("I can help", session.LastTurn.ReplyText); + Assert.Equal(TimeSpan.FromMilliseconds(740), session.LastTurn.Cut); + Assert.Null(session.LastTurn.Failure); + + // The transcript holds the text the caller heard, not the text the model produced. + Assert.Equal("I can help", session.Transcript[^1].Text); + Assert.DoesNotContain(session.Transcript, message => Contains(message, "long part")); + } + + [Fact] + public async Task AnInterruption_RunsEveryWriterAndLeavesTheSessionReady() + { + using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + timeout.Token, TestContext.Current.CancellationToken); + + await using (IAsyncEnumerator updates = session.RunTurnStreamingAsync("hi", linked.Token).GetAsyncEnumerator(linked.Token)) + { + Assert.True(await updates.MoveNextAsync()); + Assert.True(session.Cut(0, new TurnCut("hel", TimeSpan.FromMilliseconds(120)))); + + while (await updates.MoveNextAsync()) + { + // The stream ends on the interruption, so nothing arrives here. + } + } + + // The writers ran in their fixed order, and the counter read the stage the turn spoke in. + Assert.Equal(1, session.State.TurnIndex); + Assert.Equal(1, session.State.Read("greetingTurns")!.GetValue()); + Assert.Equal("greeting", session.Stage); + + // The session is not locked, so the caller who interrupted can speak next. + reply.OpenGate(); + TurnResult next = await session.RunTurnAsync("go on", linked.Token); + Assert.Equal(1, next.TurnIndex); + Assert.Null(next.Cut); + } + + // Probe W04 (design section 3, row "Cut before output"): a cut of a buffered turn that names nothing shown + // keeps its user message only, and the turn before it stays as it was. + [Fact] + public async Task AnInterruptionDuringABufferedTurn_CutsThatTurnWithNothingShown_AndLeavesThePreviousTurn() + { + using ScriptedChatClient reply = new("hello", " there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + TurnResult first = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + Assert.Equal("hello there.", first.ReplyText); + + reply.GateAfterFirstFragment = true; + Task running = session.RunTurnAsync("go on", TestContext.Current.CancellationToken); + Assert.True(session.Cut(1, new TurnCut(string.Empty, TimeSpan.FromMilliseconds(90)))); + + TurnResult second = await running; + + Assert.Equal(1, second.TurnIndex); + Assert.Equal(string.Empty, second.ReplyText); + Assert.NotNull(second.Cut); + Assert.Equal(["hi", "hello there.", "go on"], session.Transcript.Select(message => message.Text)); + Assert.Equal(2, session.State.TurnIndex); + } + + [Fact] + public void AnInterruptionWithNoRunningTurn_ReportsFalseAndChangesNothing() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + // A frame that arrives after the turn ended must not drop the conversation, so it answers false. + Assert.False(session.Cut(0, new TurnCut("nothing played", TimeSpan.FromMilliseconds(5)))); + + Assert.Null(session.LastTurn); + Assert.Equal(0, session.State.TurnIndex); + Assert.Empty(session.Transcript); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs new file mode 100644 index 00000000..196f0f26 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCommittedCutTests.cs @@ -0,0 +1,70 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// on a turn that already committed (design section 3, row "sealed"). + /// + public sealed class ConversationSessionCommittedCutTests + { + private static readonly TimeSpan Stuck = TimeSpan.FromSeconds(10); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Probe P1b (REPORT.md, F2): a cut that shows nothing of a committed reply leaves it carrying nothing, so + // the row goes, as a running turn's cut drops it; LiveKit adds a message only if forwarded_text + // (agent_activity.py:3335). The next model is never read an assistant message with no content. + [Fact] + public async Task EmptyCutOfACommittedReply_RemovesTheReplyRow_AndTheNextModelReadsNoEmptyMessage() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["Hello ", "there."], ["next reply"]); + (ConversationSession session, RecordingConversationStore store, _) = Create(model); + _ = await session.RunTurnAsync("hi", Ct); + + // Act + bool recorded = session.Cut(0, new TurnCut(string.Empty, null)); + await session.FlushTranscriptAsync(); + List stored = [.. store.Live(session.ConversationId).Select(row => row.Content.Role)]; + _ = await session.RunTurnAsync("and now?", Ct); + + // Assert + Assert.True(recorded); + Assert.Equal([ChatRole.User], stored); + List sent = [.. model.Requests[1].Where(message => message.Role != ChatRole.System)]; + Assert.Equal(["hi", "and now?"], sent.Select(message => message.Text)); + Assert.All(sent, message => Assert.NotEmpty(message.Contents)); + } + + // Probe P7d, owner ruling 2026-09-24: once turn 1 has started, a cut naming turn 0 is stale. It reports + // false and turn 0's reply stays as it was saved, while turn 1 still runs and commits. + [Fact] + public async Task CutOfThePreviousTurn_WhileTheNextTurnRuns_IsRefusedAndChangesNothing() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["a0"], ["a1"]); + model.GateBeforeCall = 1; + (ConversationSession session, RecordingConversationStore store, _) = Create(model); + _ = await session.RunTurnAsync("q0", Ct); + await using TurnRun running = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "q1"), origin: null, Ct); + Task runningRead = ReadToEndAsync(running, Ct); + await model.Gated.Task.WaitAsync(Stuck, Ct); + + // Act + bool stale = session.Cut(0, new TurnCut(string.Empty, null)); + _ = model.Release.TrySetResult(); + await runningRead.WaitAsync(Stuck, Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.False(stale); + Assert.Empty(store.Rewrites); + Assert.Equal(["q0", "a0", "q1", "a1"], Texts(store.Live(session.ConversationId).Select(row => row.Content))); + Assert.Equal(["q0", "a0", "q1", "a1"], Texts(session.Transcript)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs new file mode 100644 index 00000000..a4609dd2 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTestSupport.cs @@ -0,0 +1,59 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// One conversation of one agent over a scripted model, for the classes that test the Cut rule. + internal static class ConversationSessionCutTestSupport + { + internal static (ConversationSession Session, RecordingConversationStore Store, InMemoryAuditSink Sink) Create(IChatClient reply) + { + RecordingConversationStore store = new(); + InMemoryAuditSink sink = new(); + return (Create(reply, store, sink), store, sink); + } + + internal static ConversationSession Create( + IChatClient reply, IConversationStore store, InMemoryAuditSink? sink = null, TimeProvider? time = null) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ConversationSessionTestSupport.OneAgentYaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(new FakeChatClientFactory(reply)) { ConversationStore = store })["main"]; + + ConversationSessionFactory factory = new( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + extractor: null, + timeProvider: time, + observers: ConversationObservers.Standard(sink ?? new InMemoryAuditSink(), logger: null)); + + return factory.Create(); + } + + /// Reads a started turn's reply to its end on the thread pool, so the turn runs while the test goes on. + internal static Task ReadToEndAsync(TurnRun run, CancellationToken cancellationToken) + { + return Task.Run( + async () => + { + await foreach (ChatResponseUpdate _ in run.Updates.WithCancellation(cancellationToken)) + { + } + }, + cancellationToken); + } + + internal static List Texts(IEnumerable messages) + { + return [.. messages.Where(message => message.Text.Length > 0).Select(message => message.Text)]; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs new file mode 100644 index 00000000..1e2133ad --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutTests.cs @@ -0,0 +1,305 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// and the Cut rule over a whole conversation + /// (docs/handoff/2026-09-22-maf-native-engine-design.md, section 3). + /// + public sealed class ConversationSessionCutTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Inverse of probe P14a (row "Host cancel / text Stop", W05): a host cancel mid-reply keeps the user + // message and everything yielded, and the host still gets its cancellation. + [Fact] + public async Task HostCancelMidReply_KeepsTheUserMessageAndTheYieldedText() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Text("Hel", "lo ", "there")); + using CancellationTokenSource stop = new(); + + // Act + _ = await Assert.ThrowsAnyAsync(async () => + { + await foreach (ChatResponseUpdate _ in session.RunTurnStreamingAsync("hi", stop.Token)) + { + await stop.CancelAsync(); + } + }); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal(["hi", "Hel"], Texts(session.Transcript)); + Assert.Equal(1, store.Appends); + Assert.NotNull(session.LastTurn); + Assert.Equal("Hel", session.LastTurn.ReplyText); + } + + // Probe W06 through the conversation: a host that stops reading still gets the turn committed, and the + // conversation takes the next turn. + [Fact] + public async Task HostAbandonsTheStream_KeepsTheUserAndTheYieldedText_AndTheNextTurnRuns() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Sequence(["Hel", "lo ", "there"], ["next"])); + + // Act + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", Ct)) + { + if (update.Text.Length > 0) + { + break; + } + } + + TurnResult next = await session.RunTurnAsync("again", Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal(1, next.TurnIndex); + Assert.Equal(["hi", "Hel", "again", "next"], Texts(session.Transcript)); + Assert.Equal(2, store.Appends); + } + + // Inverse of probe P14b (W04, G1): an interrupt before the running turn yields anything keeps that turn's user + // message only, and the turn before it is untouched. + [Fact] + public async Task CutBeforeAnyContent_KeepsTheUserMessageOnly_AndLeavesThePreviousTurn() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["first reply"], ["second ", "reply"]); + model.GateBeforeCall = 1; + (ConversationSession session, RecordingConversationStore store, _) = Create(model); + _ = await session.RunTurnAsync("one", Ct); + Task drain = Task.Run( + async () => + { + await foreach (ChatResponseUpdate _ in session.RunTurnStreamingAsync("two", Ct)) + { + } + }, + Ct); + await model.Gated.Task.WaitAsync(Ct); + + // Act + bool recorded = session.Cut(1, new TurnCut(string.Empty, null)); + model.Release.TrySetResult(); + await drain; + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(recorded); + Assert.Equal(["one", "first reply", "two"], Texts(session.Transcript)); + Assert.Empty(store.Rewrites); + Assert.Equal(1, session.LastTurn!.TurnIndex); + Assert.Equal(string.Empty, session.LastTurn.ReplyText); + } + + // Design section 2, seams: StartTurnAsync hands back the turn's index before the reply is read, and the + // reply is the turn's streamed updates. + [Fact] + public async Task StartTurn_NamesTheTurnBeforeItsReplyIsRead() + { + // Arrange + (ConversationSession session, _, _) = Create(TurnScriptChatClient.Sequence(["first reply"], ["second ", "reply"])); + _ = await session.RunTurnAsync("one", Ct); + + // Act + await using TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + List texts = []; + await foreach (ChatResponseUpdate update in run.Updates) + { + // The trailing update is the turn's committed ids (design section 6, step E5), not text. + if (update.Contents.OfType().Any()) + { + continue; + } + + texts.Add(update.Text); + } + + // Assert + Assert.Equal(1, run.TurnIndex); + Assert.Equal(["second ", "reply"], texts); + Assert.Equal(1, session.LastTurn!.TurnIndex); + } + + // Probe W04 (G1) on the voice seam: a cut that reaches a started turn before its reply is read keeps the + // user message only, and the turn before it is untouched. + [Fact] + public async Task CutBeforeTheReplyIsRead_KeepsTheUserMessageOnly_AndLeavesThePreviousTurn() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Sequence(["first reply"], ["second reply"])); + _ = await session.RunTurnAsync("one", Ct); + await using TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + + // Act + bool recorded = session.Cut(run.TurnIndex, new TurnCut(string.Empty, null)); + await foreach (ChatResponseUpdate _ in run.Updates) + { + } + + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(recorded); + Assert.Equal(["one", "first reply", "two"], Texts(session.Transcript)); + Assert.Equal(2, store.Appends); + Assert.Empty(store.Rewrites); + Assert.Equal(1, session.LastTurn!.TurnIndex); + Assert.Equal(string.Empty, session.LastTurn.ReplyText); + } + + // Section 3, row "sealed": a cut of a committed turn rewrites its reply, never appends, and raises + // reply.interrupted amending turn.completed, with no duration when nothing played. + [Fact] + public async Task CutOfACommittedTurn_RewritesTheReply_AndAmendsTurnCompletedWithoutADuration() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("Hello ", "there")); + _ = await session.RunTurnAsync("hi", Ct); + + // Act + bool recorded = session.Cut(0, new TurnCut("Hello", Played: null)); + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(recorded); + Assert.Equal(["hi", "Hello"], Texts(session.Transcript)); + Assert.Equal(1, store.Appends); + _ = Assert.Single(store.Rewrites); + IReadOnlyList events = sink.EventsOf(session.ConversationId); + AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); + AuditEvent amendment = Assert.Single(events, item => item.Kind == AuditEventKind.ReplyInterrupted); + Assert.Equal(completed.EventId, amendment.AmendsEventId); + Assert.Equal(AuditHash.OfText("Hello").Value, amendment.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + Assert.False(amendment.Payload.ContainsKey(AuditPayloadKeys.DurationUntilInterruptMs)); + } + + // Section 3, row "unknown or older": false, and nothing changes. + [Fact] + public async Task CutOfAnUnknownOrOlderTurn_ReportsFalseAndChangesNothing() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Sequence(["a1"], ["a2"])); + _ = await session.RunTurnAsync("q1", Ct); + _ = await session.RunTurnAsync("q2", Ct); + + // Act + bool older = session.Cut(0, new TurnCut("a", null)); + bool unknown = session.Cut(7, new TurnCut("a", null)); + await session.FlushTranscriptAsync(); + + // Assert + Assert.False(older); + Assert.False(unknown); + Assert.Empty(store.Rewrites); + Assert.Equal(["q1", "a1", "q2", "a2"], Texts(session.Transcript)); + } + + // Probe W10: a cut racing the turn's completion, 300 runs. Always one append and at most one rewrite, the + // final text is the shown text, and both branches (cut while running, rewrite after the seal) are hit. + [Fact] + public async Task CutRacesCompletion_AlwaysOneAppend_AtMostOneRewrite_FinalTextIsShown_BothBranchesHit() + { + Random random = new(7); + int running = 0; + int amended = 0; + for (int i = 0; i < 300; i++) + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Text("Hel", "lo"); + model.FragmentDelayMs = 2; + (ConversationSession session, RecordingConversationStore store, _) = Create(model); + int wait = random.Next(0, 12); + + // Act + Task run = Task.Run( + async () => + { + await foreach (ChatResponseUpdate _ in session.RunTurnStreamingAsync("hi", Ct)) + { + } + }, + Ct); + Task cut = Task.Run( + async () => + { + await model.Called.Task.WaitAsync(Ct); + await Task.Delay(wait, Ct); + return session.Cut(0, new TurnCut("Hel", null)); + }, + Ct); + await Task.WhenAll(run, cut); + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(await cut); + Assert.Equal(1, store.Appends); + Assert.True(store.Rewrites.Count <= 1); + Assert.Equal(["hi", "Hel"], Texts(session.Transcript)); + Assert.Equal(["hi", "Hel"], Texts(store.Live(session.ConversationId).Select(row => row.Content))); + if (store.Rewrites.Count == 0) + { + running++; + } + else + { + amended++; + } + } + + Assert.True(running > 0, $"running={running}"); + Assert.True(amended > 0, $"amended={amended}"); + } + + // Owner ruling 2026-09-23: disposing a run nobody read frees the conversation, and the turn commits nothing. + [Fact] + public async Task AnUnreadRunDisposed_FreesTheConversationForTheNextTurn() + { + // Arrange + (ConversationSession session, _, _) = Create(TurnScriptChatClient.Sequence(["first reply"], ["second reply"])); + _ = await session.RunTurnAsync("one", Ct); + TurnRun unread = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + + // Act + await unread.DisposeAsync(); + TurnResult next = await session.RunTurnAsync("three", Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal("second reply", next.ReplyText); + Assert.Equal(["one", "first reply", "three", "second reply"], Texts(session.Transcript)); + } + + // Owner ruling 2026-09-23: a half-read run disposed frees the conversation, and disposing it twice is safe. + [Fact] + public async Task AHalfReadRunDisposedTwice_FreesTheConversationForTheNextTurn() + { + // Arrange + (ConversationSession session, _, _) = Create(TurnScriptChatClient.Sequence(["Hel", "lo"], ["next reply"])); + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "one"), origin: null, Ct); + IAsyncEnumerator reader = run.Updates.GetAsyncEnumerator(Ct); + Assert.True(await reader.MoveNextAsync()); + + // Act + await run.DisposeAsync(); + await run.DisposeAsync(); + TurnResult next = await session.RunTurnAsync("two", Ct); + + // Assert + Assert.Equal("next reply", next.ReplyText); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs new file mode 100644 index 00000000..4e73fe37 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionCutWindowTests.cs @@ -0,0 +1,130 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// at the edges of a turn's window: after its commit, after its run was + /// abandoned, and after the conversation ended (docs/handoff/2026-09-22-maf-native-engine-design.md, section 3). + /// + public sealed class ConversationSessionCutWindowTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // TurnCutSlot remarks: a cut after the commit belongs to the committed turn. It lands while the reader still + // holds the turn's last update, so the run has not ended, and it rewrites the reply the commit wrote. + [Fact] + public async Task ACutAfterTheCommit_WhileTheRunIsStillOpen_RewritesTheCommittedReply() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("Hello ", "there")); + await using TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "hi"), origin: null, Ct); + + // Act + bool? recorded = null; + await foreach (ChatResponseUpdate update in run.Updates) + { + if (update.Contents.OfType().Any()) + { + recorded = session.Cut(run.TurnIndex, new TurnCut("Hello", Played: null)); + } + } + + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(recorded); + Assert.Equal(["hi", "Hello"], Texts(session.Transcript)); + Assert.Equal(1, store.Appends); + _ = Assert.Single(store.Rewrites); + _ = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + } + + // Owner ruling 2026-09-23: a run disposed unread commits nothing, so a cut that names it has no turn to reach. + [Fact] + public async Task ACutOfARunDisposedUnread_ReportsFalse_AndChangesNothing() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Sequence(["first reply"], ["second reply"])); + _ = await session.RunTurnAsync("one", Ct); + TurnRun unread = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + await unread.DisposeAsync(); + + // Act + bool recorded = session.Cut(unread.TurnIndex, new TurnCut("sec", null)); + await session.FlushTranscriptAsync(); + + // Assert + Assert.False(recorded); + Assert.Equal(["one", "first reply"], Texts(session.Transcript)); + Assert.Empty(store.Rewrites); + } + + // AmendTurn: nothing may be appended behind conversation.ended, so a cut after the end records nothing. + [Fact] + public async Task ACutAfterTheConversationEnded_ReportsFalse_AndChangesNothing() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("Hello ", "there")); + _ = await session.RunTurnAsync("hi", Ct); + _ = session.EndConversation(ConversationEndReason.CallerHungUp); + + // Act + bool recorded = session.Cut(0, new TurnCut("Hello", null)); + await session.FlushTranscriptAsync(); + + // Assert + Assert.False(recorded); + Assert.Empty(store.Rewrites); + Assert.Equal(AuditEventKind.ConversationEnded, sink.EventsOf(session.ConversationId)[^1].Kind); + Assert.DoesNotContain(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + } + + // IConversationPort.Cut: a negative played duration is refused. + [Fact] + public async Task ACutWithANegativePlayedDuration_IsRefused() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Text("Hello ", "there")); + _ = await session.RunTurnAsync("hi", Ct); + + // Act + _ = Assert.Throws(() => session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(-1)))); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Empty(store.Rewrites); + Assert.Equal(["hi", "Hello there"], Texts(session.Transcript)); + } + + // ConversationSession.LastReplyMessageId names the last message written. A turn cut before any word wrote + // only the user's message, so that is the one an edit hangs off. + [Fact] + public async Task ATurnCutBeforeAnyWord_NamesTheUserMessageAsTheLastWritten() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Sequence(["first reply"], ["second reply"])); + _ = await session.RunTurnAsync("one", Ct); + await using TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + + // Act + _ = session.Cut(run.TurnIndex, new TurnCut(string.Empty, null)); + await foreach (ChatResponseUpdate _ in run.Updates) + { + } + + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal("two", store.Rows[^1].Content.Text); + Assert.Equal(store.Rows[^1].MessageId, session.LastReplyMessageId); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs index 799a8cec..8e3a5c32 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEditTests.cs @@ -146,6 +146,32 @@ public async Task AnOriginThatNamesNoParent_WithdrawsNothing() Assert.DoesNotContain(observer.Events, raised => raised.Kind == ConversationEventKind.TurnSuperseded); } + // Clarifications.Withdraw: an edit-and-resend takes back what was last named to the caller, and leaves + // the ask counter alone. + [Fact] + public async Task AnEdit_TakesBackWhatWasLastNamedToTheCaller() + { + using ScriptedChatClient reply = new("an answer."); + ConversationSession session = CreateSession(OneAgentYaml, reply); + _ = await session.RunTurnAsync("q1", TestContext.Current.CancellationToken); + string? firstReply = session.LastReplyMessageId; + _ = await session.RunTurnAsync("q2", TestContext.Current.CancellationToken); + session.Clarifications.Update("brand", slot => + { + slot.LastNamed = Clarifications.LastNamed.Of(new HashSet(StringComparer.Ordinal) { "ct900" }); + slot.ProbeAsks = 2; + }); + + _ = await session.RunTurnAtOriginAsync( + "q2, rewritten", + new ConversationTurnOrigin("caller-3", firstReply) { NamesParent = true }, + TestContext.Current.CancellationToken); + + Clarifications.SlotSnapshot after = session.Clarifications.Read("brand"); + Assert.Equal(Clarifications.LastNamed.None, after.LastNamed); + Assert.Equal(2, after.ProbeAsks); + } + private static ConversationSession CreateSession( string yaml, IChatClient reply, IConversationObserver? observer = null) { diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs new file mode 100644 index 00000000..6a4fed12 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionEmptyReplyTests.cs @@ -0,0 +1,75 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Section 8.7, last row: the run returns quietly with no text. + public sealed class ConversationSessionEmptyReplyTests + { + [Fact] + public async Task AnEmptyReply_SpeaksTheFallbackAndReportsWhyTheTurnFailed() + { + using SequencedChatClient reply = new(" "); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // Request 41 goes out with no tools and the run returns with no exception. On a voice conversation + // that is silence, so the turn loop reads the reply rather than trusting the absence of a + // failure. + Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); + Assert.Equal(ConversationSession.EmptyReplyReason, turn.Failure); + Assert.Null(turn.Cut); + + // The writers still ran, and the transcript holds what the caller heard. + Assert.Equal(1, session.State.TurnIndex); + Assert.Equal(1, session.State.Read("greetingTurns")!.GetValue()); + Assert.Equal(ConversationSession.FallbackReply, session.Transcript[^1].Text); + } + + [Fact] + public async Task AnEmptyReply_LeavesTheSessionReadyForTheNextTurn() + { + using SequencedChatClient reply = new("", "I am back."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + TurnResult first = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + TurnResult second = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); + + Assert.Equal(ConversationSession.FallbackReply, first.ReplyText); + Assert.Equal("I am back.", second.ReplyText); + Assert.Null(second.Failure); + Assert.Equal(1, second.TurnIndex); + } + + [Fact] + public async Task Streaming_SpeaksTheFallbackWhenTheRunProducesNoText() + { + using LifecycleChatClient reply = new(); + ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // The host hears the fallback rather than nothing at all: R2 makes a quiet run an ordinary + // successful one, and the spoken line leaves the seam the same way any reply does. The + // trailing update is the turn's committed ids (design section 6, step E5), not more text. + Assert.Equal( + [ConversationSession.FallbackReply], + updates.Where(update => !update.Contents.OfType().Any()).Select(update => update.Text)); + Assert.NotNull(session.LastTurn); + Assert.Equal(ConversationSession.FallbackReply, session.LastTurn.ReplyText); + Assert.Equal(ConversationSession.EmptyReplyReason, session.LastTurn.Failure); + Assert.Equal("close", session.Stage); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs new file mode 100644 index 00000000..5abc8a54 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionFactoryTests.cs @@ -0,0 +1,76 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Runtime; +using AgentCore.Application.State; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// The factory: conversation ids, per-conversation state, and the extractor it builds. + public sealed class ConversationSessionFactoryTests + { + // The factory. + [Fact] + public void TheFactory_MakesAConversationIdWhenTheHostGivesNone() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSessionFactory factory = Build(PolicyYaml, reply, fill); + + ConversationSession first = factory.Create(); + ConversationSession second = factory.Create(); + + Assert.NotEmpty(first.ConversationId); + Assert.NotEqual(first.ConversationId, second.ConversationId); + } + + [Fact] + public async Task TheFactory_GivesEachConversationItsOwnStateAndItsOwnMachine() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(SaidGoodbye, StayingNull); + ConversationSessionFactory factory = Build(PolicyYaml, reply, fill); + + ConversationSession first = factory.Create("conversation-1"); + ConversationSession second = factory.Create("conversation-2"); + + _ = await first.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + + // One machine belongs to one conversation. The first conversation ended, and the second one has not started. + Assert.True(first.IsComplete); + Assert.False(second.IsComplete); + Assert.Equal("greeting", second.Stage); + Assert.Empty(second.Transcript); + Assert.NotSame(first.State, second.State); + + // Both conversations share the one compiled agent. T44 and rule 16. + Assert.Same(first.Compiled, second.Compiled); + } + + [Fact] + public void TheFactory_BuildsNoExtractorWhenTheDocumentDeclaresNone() + { + using SequencedChatClient reply = new("hello there."); + CompiledAgent compiled = Compile(TwoStagesYaml, reply, null, null); + + Assert.Null(ConversationSessionFactory.CreateExtractor(compiled, new FakeChatClientFactory(reply))); + } + + [Fact] + public void TheFactory_BuildsTheExtractorTheDocumentDeclares() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + CompiledAgent compiled = Compile(PolicyYaml, reply, fill, null); + + StateExtractor? extractor = ConversationSessionFactory.CreateExtractor( + compiled, + new RoutingChatClientFactory(reply).Route("fill", fill)); + + Assert.NotNull(extractor); + Assert.Equal(["callerSaidGoodbye"], extractor.SlotNames); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs new file mode 100644 index 00000000..71455560 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphCancelTests.cs @@ -0,0 +1,147 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime; +using AgentCore.Domain; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// F2 of the 2026-09-24 round: on a graph row, a caller cancel is a cancel and a model timeout is a fault. + /// MAF's workflow agent throws for neither; it reports a fault as an update and ends its stream quietly on + /// a cancel, so has to raise both. + /// + public sealed class ConversationSessionGraphCancelTests + { + private const string SequentialYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: first, instructions: "one", model: { ref: first } } + - { id: second, instructions: "two", model: { ref: second } } + entries: + main: + graph: + pattern: sequential + agents: [ first, second ] + """; + + private const string ExplicitYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: first, instructions: "one", model: { ref: first } } + - { id: second, instructions: "two", model: { ref: second } } + entries: + main: + graph: + nodes: + - { id: start, agent: first, start: true } + - { id: finish, agent: second, output: true } + edges: + - { from: start, to: finish } + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// + /// Claim (F2): HttpClient reports a request timeout as even when + /// nobody cancelled the turn. That must still reach the turn as a run fault that speaks the fallback, + /// not escape as a cancel. + /// + [Fact] + public async Task Fault_GraphNodeTimeout_SpeaksTheFallback_InsteadOfEscapingAsACancel() + { + ConversationSession session = GraphConversationSessions.Create(SequentialYaml, new TimesOutOnceModel(), new DownModelChatClient(), static _ => null, Ct); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + + Assert.StartsWith(ConversationSession.RunFaultReason, turn.Failure, StringComparison.Ordinal); + Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, turn.ReplyText); + } + + /// + /// Control for F2: a real caller cancel must still propagate as a cancel, and the stopped turn must not + /// keep the fallback line as its reply. A cancel is not a fault, so nothing spoke that line. + /// + [Theory] + [InlineData(SequentialYaml)] + [InlineData(ExplicitYaml)] + public async Task Fault_ARealCallerCancel_StillPropagatesAsACancel(string yaml) + { + using HangUntilCancelledChatClient first = new(); + ConversationSession session = GraphConversationSessions.Create(yaml, first, new DownModelChatClient(), static _ => null, Ct); + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(Ct); + + Task run = session.RunTurnAsync("go", cts.Token); + await first.Started.WaitAsync(TimeSpan.FromSeconds(10), Ct); + cts.Cancel(); + + _ = await Assert.ThrowsAnyAsync(() => run); + Assert.NotNull(session.LastTurn); + Assert.Null(session.LastTurn.Failure); + Assert.Empty(session.LastTurn.ReplyText); + } + + /// + /// The same claim one layer down, with no turn loop above it. The turn loop's own notice reader also + /// watches the caller's token and can throw first, which hid this in the session test about two runs in + /// three; here nothing but can turn MAF's quiet end into a cancel. R4-3: + /// the cancel check runs the same way whether the row drains the inner stream or throws at the first + /// fault, so this must hold on both. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GraphFaultAgent_CallerCancelDuringANode_ThrowsACancel(bool drain) + { + using HangUntilCancelledChatClient model = new(); + AIAgent workflow = AgentWorkflowBuilder + .BuildSequential("g", [new ChatClientAgent(model, new ChatClientAgentOptions { Name = "first" })]) + .AsAIAgent(name: "g"); + GraphFaultAgent agent = new(workflow, drain); + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(Ct); + + Task run = DrainAsync(agent.RunStreamingAsync("go", cancellationToken: cts.Token)); + await model.Started.WaitAsync(TimeSpan.FromSeconds(10), Ct); + cts.Cancel(); + + _ = await Assert.ThrowsAnyAsync(() => run); + } + + private static async Task DrainAsync(IAsyncEnumerable updates) + { + await foreach (AgentResponseUpdate _ in updates) + { + } + } + + /// A model that throws a timeout-shaped on its first call, as HttpClient does when nobody cancelled. + private sealed class TimesOutOnceModel : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + throw new TaskCanceledException("HttpClient.Timeout of 100 seconds elapsing.", new TimeoutException()); +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => throw new TaskCanceledException("HttpClient.Timeout of 100 seconds elapsing.", new TimeoutException()); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs new file mode 100644 index 00000000..0d6cdad7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphFaultTests.cs @@ -0,0 +1,213 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Bug 2 of the 2026-09-24 round: a graph node's fault must reach the turn, on both graph rows. + /// AsAIAgent() reports a node fault as an update and never throws, + /// so without the turn read an empty reply and lost the tool-versus-run + /// distinction carries. + /// + public sealed class ConversationSessionGraphFaultTests + { + private const string SequentialToolYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + items: + - { id: first, instructions: "quote", tools: [ price_lookup ], model: { ref: first } } + - { id: second, instructions: "two", model: { ref: second } } + entries: + main: + graph: + pattern: sequential + agents: [ first, second ] + """; + + private const string ExplicitToolYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + items: + - { id: first, instructions: "quote", tools: [ price_lookup ], model: { ref: first } } + - { id: second, instructions: "two", model: { ref: second } } + entries: + main: + graph: + nodes: + - { id: start, agent: first, start: true } + - { id: finish, agent: second, output: true } + edges: + - { from: start, to: finish } + """; + + private const string TodosYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: first, instructions: "track todos", todos: true, model: { ref: first } } + - { id: second, instructions: "echo back", model: { ref: second } } + entries: + main: + graph: + pattern: sequential + agents: [ first, second ] + """; + + private const string ConcurrentNoStateYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: slow, instructions: "take your time", model: { ref: slow } } + - { id: broken, instructions: "fail", model: { ref: broken } } + entries: + main: + graph: + pattern: concurrent + agents: [ slow, broken ] + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(SequentialToolYaml)] + [InlineData(ExplicitToolYaml)] + public async Task Fault_ToolFaultInsideAGraphNode_IsStillAToolFault(string yaml) + { + using CallForever first = new(); + ConversationSession session = GraphConversationSessions.Create(yaml, first, new DownModelChatClient(), ThrowingToolBuilder.Create, Ct); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + + Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); + + // Not just the right bucket: the tool's own fault, not RequireOutputAgent's unrelated + // "produced no text" message, which an explicit graph with no GraphFaultAgent throws too + // and would otherwise pass this assertion for the wrong reason. + Assert.Contains("the tool is down.", turn.Failure, StringComparison.Ordinal); + } + + [Theory] + [InlineData(SequentialToolYaml)] + [InlineData(ExplicitToolYaml)] + public async Task Fault_ModelDownInsideAGraphNode_IsARunFault(string yaml) + { + ConversationSession session = GraphConversationSessions.Create(yaml, new DownModelChatClient(), new DownModelChatClient(), ThrowingToolBuilder.Create, Ct); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + + Assert.StartsWith(ConversationSession.RunFaultReason, turn.Failure, StringComparison.Ordinal); + + // Same reason as above: the model's own fault must reach the turn, not RequireOutputAgent's + // unrelated "produced no text" message. + Assert.Contains("503 from the model endpoint", turn.Failure, StringComparison.Ordinal); + } + + /// + /// Claim (R4-3): a concurrent graph whose agents keep no harness state never reuses its workflow + /// session ( is false), so + /// must throw at the broken node's first fault instead of draining the still-open slow node. The slow + /// node's gate is never opened before the assertion, so a regression that waits for it would time out + /// here rather than merely run slow. + /// + [Fact] + public async Task Fault_ConcurrentGraphWithNoReusedSession_SpeaksTheFallbackWithoutWaitingForTheSlowNode() + { + using GatedChatClient slow = new(new ScriptedChatClient("slow answer")); + slow.Arm(); + using DownModelChatClient broken = new(); + RoutingChatClientFactory clients = new(slow); + _ = clients.Route("slow", slow); + _ = clients.Route("broken", broken); + + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ConcurrentNoStateYaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(clients))["main"]; + await using ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(); + + try + { + TurnResult turn = await session.RunTurnAsync("go", Ct).WaitAsync(TimeSpan.FromSeconds(2), Ct); + + Assert.Equal(AgentCoreConfiguration.DefaultFallbackReply, turn.ReplyText); + } + finally + { + slow.Open.TrySetResult(); + } + } + + /// + /// Claim (F3): a graph row that reuses its workflow session must not send a faulted turn's input into + /// the next turn. GraphFaultAgent must let MAF's stream finish (so AddMessages and + /// UpdateBookmark run) before it throws. + /// + [Fact] + public async Task Fault_ReusedGraphSession_AfterANodeFault_TheNextTurnDoesNotReplayTheFaultedInput() + { + RequestCapturingChatClient first = new(new FailsOnceThenAnswersModel()); + ConversationSession session = GraphConversationSessions.Create(TodosYaml, first, new DownModelChatClient(), static _ => null, Ct); + + TurnResult turnOne = await session.RunTurnAsync("zebra first input", Ct); + Assert.NotNull(turnOne.Failure); + + _ = await session.RunTurnAsync("second input", Ct); + + IReadOnlyList turnTwoRequest = first.Requests[^1]; + Assert.DoesNotContain( + turnTwoRequest, + message => message.Role == ChatRole.User && message.Text.Contains("zebra first input", StringComparison.Ordinal)); + } + + /// + /// Claim (F9): MAF documents as possibly null. Without a + /// name-the-node fallback, that event's passes through unread and the turn + /// sees an empty reply. + /// + [Fact] + public async Task Fault_ExecutorFailedEventWithNoException_ThrowsNamingTheFailedNode() + { + GraphFaultAgent agent = new(new RawEventAgent(new ExecutorFailedEvent("blocked-node", null)), drain: true); + + InvalidOperationException thrown = await Assert.ThrowsAsync(async () => + { + await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync("hi", cancellationToken: Ct)) + { + } + }); + + Assert.Contains("blocked-node", thrown.Message, StringComparison.Ordinal); + } + + /// A tool that always throws. Every call is one of the turn's own retries, not a new turn. + private static class ThrowingToolBuilder + { + public static AIFunction? Create(ToolConfiguration tool) + { + ArgumentNullException.ThrowIfNull(tool); + return AIFunctionFactory.Create(Fail, tool.Id, tool.Description ?? tool.Id); + } + + private static string Fail() + { + throw new TimeoutException("the tool is down."); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs new file mode 100644 index 00000000..a9a5ad61 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphIdTests.cs @@ -0,0 +1,167 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Bug 1 of the 2026-09-24 round: a graph node's checkpoint must resume under a second compile of the + /// same document (a restart, or a second host). MAF's InProcessRunner matches a checkpoint by + /// executor id, and an agent's executor id folds in + /// (AIAgentExtensions.GetDescriptiveId). gives every agent a + /// stable id, so two compiles of one document bind the same executor ids. + /// + public sealed class ConversationSessionGraphIdTests + { + private const string SequentialTodos = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: adder, instructions: "track todos", todos: true, model: { ref: adder } } + - { id: echo, instructions: "echo back", model: { ref: echo } } + entries: + main: + graph: + pattern: sequential + agents: [ adder, echo ] + """; + + private const string ExplicitTodos = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: adder, instructions: "track todos", todos: true, model: { ref: adder } } + - { id: echo, instructions: "echo back", model: { ref: echo } } + entries: + main: + graph: + nodes: + - { id: start, agent: adder, start: true } + - { id: finish, agent: echo, output: true } + edges: + - { from: start, to: finish } + """; + + private const string PatternDashUnderscore = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: order-lookup, instructions: "one", model: { ref: dash } } + - { id: order_lookup, instructions: "two", model: { ref: under } } + entries: + main: + graph: + pattern: sequential + agents: [ order-lookup, order_lookup ] + """; + + private const string ExplicitDashUnderscore = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: order-lookup, instructions: "one", model: { ref: dash } } + - { id: order_lookup, instructions: "two", model: { ref: under } } + entries: + main: + graph: + nodes: + - { id: start, agent: order-lookup, start: true } + - { id: finish, agent: order_lookup, output: true } + edges: + - { from: start, to: finish } + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// + /// Claim: order-lookup and order_lookup are distinct, schema-valid ids. Before the fix, + /// Id = item.Id let MAF's sanitizer (Regex.Replace(Name + "_" + Id, "[^0-9A-Za-z]+", "_")) + /// collapse them to the same executor id, and the compile threw. Both nodes must run and the graph + /// must answer with the second node's reply. + /// + [Theory] + [InlineData(PatternDashUnderscore)] + [InlineData(ExplicitDashUnderscore)] + public async Task Graph_TwoAgentIdsDifferingOnlyByDashAndUnderscore_BothRun(string yaml) + { + RoutingChatClientFactory clients = new(new TextModel("from dash")); + _ = clients.Route("dash", new TextModel("from dash")); + _ = clients.Route("under", new TextModel("from under")); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), + new AgentCompilationContext(clients) { ConversationStore = new InMemoryConversationStore() })["main"]; + ConversationSession session = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create("c1"); + + TurnResult turn = await session.RunTurnAsync("go", Ct); + + Assert.Null(turn.Failure); + Assert.Equal("from under", turn.ReplyText); + } + + private static ConversationSessionFactory Factory(string yaml, InMemoryConversationStore store) + { + RoutingChatClientFactory clients = new(new TextModel("added")); + _ = clients.Route("adder", new TextModel("added")); + _ = clients.Route("echo", new TextModel("echo")); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), + new AgentCompilationContext(clients) { ConversationStore = store })["main"]; + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + } + + [Theory] + [InlineData(SequentialTodos)] + [InlineData(ExplicitTodos)] + public async Task Graph_TwoCompilesAlternating_EveryTurnResumes(string yaml) + { + InMemoryConversationStore store = new(); + ConversationSession a = Factory(yaml, store).Create("g1"); + ConversationSession b = Factory(yaml, store).Create("g1"); + List faults = []; + + foreach ((string who, ConversationSession s) in new[] { ("A", a), ("B", b), ("A", a), ("B", b), ("A", a), ("A", a), ("B", b) }) + { + TurnResult t = await s.RunTurnAsync("hi", Ct); + if (t.Failure is not null) + { + faults.Add($"{who}{t.TurnIndex}: {t.Failure}"); + } + } + + Assert.Empty(faults); + } + + private sealed class TextModel(string text) : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + string id = Guid.NewGuid().ToString("N"); + yield return new ChatResponseUpdate(ChatRole.Assistant, text) { ResponseId = id, MessageId = id }; + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs index 26d96a90..6165c8bd 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionGraphReplyTests.cs @@ -6,6 +6,7 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; using AgentCore.Application.Tests.Fakes; using AgentCore.Domain.Audit; using Microsoft.Extensions.AI; @@ -183,6 +184,29 @@ public async Task Streaming_GraphRow_StreamsEveryFragmentOfTheFinalNodeAndNoneOf Assert.Null(session.LastTurn.Failure); } + [Fact] + public async Task Streaming_GraphRow_EndsWithTheIdsOfItsAppend() + { + // Arrange. Design section 6, step E5: the committed ids ride the turn's last update, on a row whose + // output is filtered to its final node as on any other. + using ScriptedChatClient researcher = new(Thinking); + using ScriptedChatClient responder = new(Spoken); + ConversationSession session = Build(GraphYaml, researcher, responder, new InMemoryAuditSink()).Create("conversation-ids"); + + // Act. + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync( + "where is my order", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // Assert. The last update names what the append wrote, and the reply id is the last message written. + TurnCommittedContent committed = Assert.Single(updates[^1].Contents.OfType()); + Assert.NotNull(committed.ReplyMessageId); + Assert.Equal(session.LastReplyMessageId, committed.ReplyMessageId); + } + /// Compiles one configuration over offline models and returns the session factory. /// The configuration to compile. /// The model every unrouted agent uses. diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs index 8ccedb62..9b565163 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionInterruptionTests.cs @@ -1,17 +1,15 @@ -using AgentCore.TestSupport; using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Tests.Fakes; -using AgentCore.Domain.Audit; using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; using Microsoft.Extensions.AI; -using System.Runtime.CompilerServices; using Xunit; +using static AgentCore.Application.Tests.Runtime.InterruptionSessions; namespace AgentCore.Application.Tests.Runtime { @@ -24,65 +22,7 @@ namespace AgentCore.Application.Tests.Runtime /// public sealed class ConversationSessionInterruptionTests { - private const string NoToolYaml = - """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "I answer everything" } - entries: - main: - agent: only - """; - - private const string ToolYaml = - """ - apiVersion: agentcore/v1 - tools: - - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } - agents: - items: - - { id: only, instructions: "quote the price", tools: [ price_lookup ] } - entries: - main: - agent: only - """; - - private const string ParallelToolYaml = - """ - apiVersion: agentcore/v1 - tools: - - { id: quote, kind: builtin, uses: orders.read, description: "Get a price quote for an item." } - agents: - items: - - { id: only, instructions: "quote both items", tools: [ quote ] } - entries: - main: - agent: only - """; - - private const string ExtractorYaml = - """ - apiVersion: agentcore/v1 - state: - callerName: - type: string - writer: extractor - description: the name the caller gave - extractor: - model: { ref: fill } - when: after_reply - agents: - items: - - { id: only, instructions: "greet the caller" } - entries: - main: - agent: only - """; - - // ------------------------------------------------------------------------------------------- // What the transcript holds after the caller cuts the reply off. - // ------------------------------------------------------------------------------------------- [Fact] public async Task ABargeInBeforeTheFirstWord_AddsNoAssistantMessage() @@ -138,9 +78,7 @@ public async Task AParallelToolRound_KeepsTheConversationThatFinishedAndDropsThe using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( timeout.Token, TestContext.Current.CancellationToken); - // Only the streaming entry point is ever cut in flight — a turn that never streams is never - // audible, so a barge-in cannot cancel it mid-round. The streaming shape is also the one a - // relay actually drives. + // The streaming shape is the one a relay drives. Task pump = Task.Run( async () => { @@ -154,7 +92,7 @@ public async Task AParallelToolRound_KeepsTheConversationThatFinishedAndDropsThe // The first call already answered by the time the second call is blocked in flight. await tools.SecondConversationStarted.Task.WaitAsync(linked.Token); - Assert.True(session.Interrupt("the first one is", TimeSpan.FromMilliseconds(1820))); + Assert.True(session.Cut(0, new TurnCut("the first one is", TimeSpan.FromMilliseconds(1820)))); await pump; @@ -177,35 +115,30 @@ public async Task AParallelToolRound_KeepsTheConversationThatFinishedAndDropsThe } [Fact(Timeout = 30_000)] - public async Task ProseBesideAFinishedToolCall_LeavesTheTranscriptOnTheHeardTextAlone() + public async Task ProseBesideAFinishedToolCall_KeepsTheProseInPlaceAndCutsTheLastStep() { // A real model routinely puts a line of prose and the tool call it announces in one - // assistant message. The heard text replaces the prose, so keeping both would put the - // reply into the transcript twice — once as the words the model produced and once as the - // words the caller actually heard. Item 6a: the record holds what the caller heard. + // assistant message, then answers in a second step. The caller heard the prose whole and + // part of the answer, so the prose stays before its call and only the answer is cut + // (owner ruling 2026-09-23). The cut names every word heard, across steps. await using InterruptionFixture fixture = InterruptionFixture.StartWithProseBesideTool(reply: "the price is fifty"); - _ = await fixture.InterruptAfterFirstUpdateAsync(heard: "the price"); + _ = await fixture.InterruptAfterFirstUpdateAsync(heard: ProseBesideToolChatClient.Prose + "the price"); - // The side effect ran, so the pair stays. That is the rule this fact must not break. - Assert.Contains(fixture.Session.Transcript, m => m.Contents.OfType().Any()); - Assert.Contains(fixture.Session.Transcript, m => m.Contents.OfType().Any()); - - Assert.DoesNotContain( - fixture.Session.Transcript.SelectMany(m => m.Contents).OfType(), - text => text.Text.Contains(ProseBesideToolChatClient.Prose, StringComparison.Ordinal)); - - ChatMessage heard = Assert.Single( - fixture.Session.Transcript, - m => m.Role == ChatRole.Assistant && m.Contents.All(content => content is TextContent)); - Assert.Equal("the price", heard.Text); + Assert.Equal( + [ChatRole.User, ChatRole.Assistant, ChatRole.Tool, ChatRole.Assistant], + fixture.Session.Transcript.Select(m => m.Role)); + + ChatMessage announced = fixture.Session.Transcript[1]; + Assert.Equal(ProseBesideToolChatClient.Prose, announced.Text); + _ = Assert.Single(announced.Contents.OfType()); + _ = Assert.Single(fixture.Session.Transcript[2].Contents.OfType()); + Assert.Equal("the price", fixture.Session.Transcript[3].Text); } - // ------------------------------------------------------------------------------------------- // A barge-in that arrives after the turn task already ended. Telnyx paces the audio, so the // model finishes streaming long before the vendor finishes speaking, and this is the common // shape on a real conversation rather than an edge case. - // ------------------------------------------------------------------------------------------- [Fact(Timeout = 30_000)] public async Task AnInterruptAfterTheTurnEnded_AmendsTheFinishedTurn() @@ -223,12 +156,12 @@ public async Task AnInterruptAfterTheTurnEnded_AmendsTheFinishedTurn() } Assert.NotNull(session.LastTurn); - Assert.Null(session.LastTurn!.InterruptedAfter); + Assert.Null(session.LastTurn!.Cut); - Assert.True(session.Interrupt("Hello there", TimeSpan.FromMilliseconds(1820))); + Assert.True(session.Cut(0, new TurnCut("Hello there", TimeSpan.FromMilliseconds(1820)))); Assert.Equal("Hello there", session.LastTurn!.ReplyText); - Assert.Equal(TimeSpan.FromMilliseconds(1820), session.LastTurn.InterruptedAfter); + Assert.Equal(TimeSpan.FromMilliseconds(1820), session.LastTurn.Cut); // The transcript holds what the caller heard, not what the model produced. ChatMessage assistant = Assert.Single(session.Transcript, m => m.Role == ChatRole.Assistant); @@ -246,7 +179,7 @@ public async Task AnInterruptAfterTheTurnEnded_AmendsTheFinishedTurn() Assert.Equal("1820", amendment.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); // One barge-in cuts one reply once. A repeat frame amends nothing a second time. - Assert.False(session.Interrupt("Hello there caller", TimeSpan.FromMilliseconds(2400))); + Assert.False(session.Cut(0, new TurnCut("Hello there caller", TimeSpan.FromMilliseconds(2400)))); _ = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); } @@ -257,7 +190,7 @@ public void AnInterruptBeforeAnyTurnEverRan_ChangesNothingAndThrowsNothing() InMemoryAuditSink sink = new(); ConversationSession session = CreateSession(NoToolYaml, reply, auditSink: sink); - Assert.False(session.Interrupt("nothing played", TimeSpan.FromMilliseconds(10))); + Assert.False(session.Cut(0, new TurnCut("nothing played", TimeSpan.FromMilliseconds(10)))); Assert.Null(session.LastTurn); Assert.Empty(session.Transcript); @@ -266,12 +199,11 @@ public void AnInterruptBeforeAnyTurnEverRan_ChangesNothingAndThrowsNothing() item => item.Kind == AuditEventKind.ReplyInterrupted); } + // Probe W04 (design section 3, row "Cut before output", G1 = Cut(turn, "", null)): a cut of the running turn + // that names nothing shown keeps its user message only. [Fact(Timeout = 30_000)] - public async Task AnInterruptWhileAnUnheardSecondTurnRuns_AmendsTheFirstTurnAndLetsTheSecondSpeak() + public async Task AnInterruptWhileAnUnheardSecondTurnRuns_CutsTheSecondTurnWithNothingShown_AndLeavesTheFirst() { - // The held prompt of item 6a starts turn two inside turn one's own finally, while the - // vendor is still speaking turn one. The barge-in belongs to the turn the caller was - // hearing, so turn two must not be cut off before it has said a word. using SecondTurnGatedChatClient reply = new("first reply", "second reply"); InMemoryAuditSink sink = new(); ConversationSession session = CreateSession(NoToolYaml, reply, auditSink: sink); @@ -288,14 +220,12 @@ public async Task AnInterruptWhileAnUnheardSecondTurnRuns_AmendsTheFirstTurnAndL TurnResult? first = session.LastTurn; Assert.NotNull(first); - List spoken = []; Task second = Task.Run( async () => { - await foreach (ChatResponseUpdate? update in session.RunTurnStreamingAsync("two", bounded.Token) + await foreach (ChatResponseUpdate? _ in session.RunTurnStreamingAsync("two", bounded.Token) .ConfigureAwait(false)) { - spoken.Add(update.Text); } }, CancellationToken.None); @@ -311,7 +241,7 @@ public async Task AnInterruptWhileAnUnheardSecondTurnRuns_AmendsTheFirstTurnAndL Assert.Fail("the second turn never started within ten seconds."); } - Assert.True(session.Interrupt("first", TimeSpan.FromMilliseconds(1820))); + Assert.True(session.Cut(first.TurnIndex + 1, new TurnCut(string.Empty, null))); } finally { @@ -328,38 +258,36 @@ public async Task AnInterruptWhileAnUnheardSecondTurnRuns_AmendsTheFirstTurnAndL Assert.Fail("the second turn never finished within ten seconds."); } - // The second turn spoke every word it had. Nothing silenced it. - Assert.Equal("second reply", string.Concat(spoken)); - Assert.Null(session.LastTurn!.InterruptedAfter); + // The running turn keeps the user's words and nothing shown; the first turn is untouched. + Assert.Equal(["one", "first reply", "two"], session.Transcript.Select(message => message.Text)); + Assert.Equal(first!.TurnIndex + 1, session.LastTurn!.TurnIndex); + Assert.Equal(string.Empty, session.LastTurn.ReplyText); AuditEvent amendment = Assert.Single( sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); - Assert.Equal(first!.TurnIndex, amendment.TurnIndex); + Assert.Equal(first.TurnIndex + 1, amendment.TurnIndex); Assert.Equal( - AuditHash.OfText("first").Value, + AuditHash.OfText(string.Empty).Value, amendment.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + Assert.False(amendment.Payload.ContainsKey(AuditPayloadKeys.DurationUntilInterruptMs)); } - // ------------------------------------------------------------------------------------------- // A barge-in that lands while the turn is still finishing itself. The reply is over, the writers // are running, and the extractor can hold the turn for up to TurnCompletionTimeout — five whole - // seconds in which the run still looks live to Interrupt. - // ------------------------------------------------------------------------------------------- + // seconds in which the run still looks live to Cut. [Fact(Timeout = 30_000)] public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported() { - // CompleteTurnAsync reads the interruption once, before it awaits the extractor. EndRun runs + // The seal reads the cut once, before the extractor runs. EndRun runs // later still, in the finally of whichever method opened the turn, so a frame that lands - // inside that await finds a live run and — on a streaming turn, audible since its first - // yielded fragment — takes the in-flight path, sets the record, and answers true. And true, - // on Interrupt's own contract, means "this conversation recorded the barge-in". The commit lock - // reads the field a second time for exactly this frame. Without that read the turn commits - // as an ordinary completed turn: InterruptedAfter stays null, the transcript keeps the whole + // inside that await finds a live run, waits in the cut slot as a late cut, and answers + // true. And true, on Cut's own contract, means "this conversation recorded the cut". + // The commit takes the late cut under the turn lock for exactly this frame. Without that read the turn commits + // as an ordinary completed turn: Cut stays null, the transcript keeps the whole // reply the model produced rather than the words the caller heard, and the chain carries no - // reply.interrupted event at all. Only the streaming shape reaches this window: a turn that - // never streams is never audible, and its frame amends the turn before it instead. + // reply.interrupted event at all. using ScriptedChatClient reply = new("Hello", " there", " caller"); using GatedExtractorChatClient extractor = new(); RoutingChatClientFactory chatClients = new RoutingChatClientFactory(reply).Route("fill", extractor); @@ -399,9 +327,9 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( Assert.Fail("the extractor never started within ten seconds."); } - // The reply is complete and the turn is inside the extractor await. Interrupt says it + // The reply is complete and the turn is inside the extractor await. Cut says it // recorded this, so the rest of this test holds it to that answer. - Assert.True(session.Interrupt("Hello there", TimeSpan.FromMilliseconds(1820))); + Assert.True(session.Cut(0, new TurnCut("Hello there", TimeSpan.FromMilliseconds(1820)))); } finally { @@ -415,7 +343,7 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( // D28: both values are the ones the relay reported, unchanged. Assert.NotNull(turn); - Assert.Equal(TimeSpan.FromMilliseconds(1820), turn.InterruptedAfter); + Assert.Equal(TimeSpan.FromMilliseconds(1820), turn.Cut); Assert.Equal("Hello there", turn.ReplyText); // The transcript holds what the caller heard, and never the tail the model produced. @@ -434,13 +362,11 @@ public async Task AnInterruptWhileTheExtractorRuns_IsRecordedAndNotOnlyReported( Assert.Equal("1820", amendment.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); // One barge-in cuts one reply once. The turn is no longer amendable, so a repeat says so. - Assert.False(session.Interrupt("Hello there caller", TimeSpan.FromMilliseconds(2400))); + Assert.False(session.Cut(0, new TurnCut("Hello there caller", TimeSpan.FromMilliseconds(2400)))); _ = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); } - // ------------------------------------------------------------------------------------------- // The deadline that bounds the work after the reply. - // ------------------------------------------------------------------------------------------- [Fact] public async Task AnExtractorThatNeverReturns_DoesNotHoldTheConversationOpenForever() @@ -455,698 +381,5 @@ public async Task AnExtractorThatNeverReturns_DoesNotHoldTheConversationOpenFore Assert.NotNull(turn); Assert.Equal(ConversationSession.ExtractionTimedOutReason, fixture.LastExtractionFailure); } - - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- - - private static ConversationSession CreateSession( - string yaml, - IChatClient reply, - Func? tools = null, - IAuditSinkPort? auditSink = null) - { - // There is always a sink now: ConversationObservers.Standard takes a required one, because the - // composition root resolves providers.audit for every host and falls back to the in-process - // memory kind. An optional parameter has to be a compile-time constant, so the default is - // spelled here instead — a fact that does not care where its events land gets a fresh - // in-memory sink and reads exactly as it did when it passed nothing. - IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); - - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - FakeChatClientFactory chatClients = new(reply); - CompiledAgent compiled = ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(chatClients) - { - Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), - })["main"]; - - ConversationSessionFactory factory = new( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, chatClients), - observers: ConversationObservers.Standard(sink, logger: null)); - - return factory.Create(); - } - - /// - /// Drives one streaming turn on a background task, far enough to interrupt it. - /// - /// - /// Every fact of this file wants the same shape: a model that streams its first piece of content, - /// then holds until the test releases it. This fixture builds that turn once, over - /// and , so each fact - /// reads as one line of setup and one assertion of what the interruption left behind. - /// - private sealed class InterruptionFixture : IAsyncDisposable - { - private readonly CancellationTokenSource _cancellation; - private readonly IReadOnlyList _disposables; - private readonly Action _openGate; - private readonly TaskCompletionSource _firstUpdate = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly Task _run; - - private InterruptionFixture(ConversationSession session, IChatClient reply, Action openGate, CancellationToken hostToken) - { - Session = session; - _disposables = [reply]; - _openGate = openGate; - _cancellation = CancellationTokenSource.CreateLinkedTokenSource(hostToken); - _cancellation.CancelAfter(TimeSpan.FromSeconds(30)); - // The run reads its own token, _cancellation.Token, from inside PumpAsync, so the token - // Task.Run offers here is deliberately unused. - _run = Task.Run(PumpAsync, CancellationToken.None); - } - - /// Builds a fixture over a turn that runs directly, with no streaming pump behind it. - private InterruptionFixture(ConversationSession session, IReadOnlyList disposables, CancellationToken hostToken) - { - Session = session; - _disposables = disposables; - _openGate = static () => { }; - _cancellation = CancellationTokenSource.CreateLinkedTokenSource(hostToken); - _cancellation.CancelAfter(TimeSpan.FromSeconds(30)); - // RunTurnAsync drives the turn itself, on the caller's own conversation. There is no round to - // stream and gate, so there is nothing here for a background pump to do. - _run = Task.CompletedTask; - } - - /// Gets the session the turn runs on. - public ConversationSession Session { get; } - - /// Starts a turn over a plain text reply, gated after its first fragment. - public static InterruptionFixture Start(string reply) - { - ScriptedChatClient client = new(reply.Split(' ')) { GateAfterFirstFragment = true }; - ConversationSession session = CreateSession(NoToolYaml, client); - return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); - } - - /// Starts a turn whose tool call and result finish before the reply that follows them. - public static InterruptionFixture StartWithFinishedTool(string reply) - { - GatedToolThenReplyChatClient client = new(reply); - ConversationSession session = CreateSession(ToolYaml, client, new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create); - return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); - } - - /// Starts a turn whose tool round also carries the prose the model spoke before the call. - public static InterruptionFixture StartWithProseBesideTool(string reply) - { - ProseBesideToolChatClient client = new(reply); - ConversationSession session = CreateSession(ToolYaml, client, new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create); - return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); - } - - /// Builds a session whose extractor model never answers, so the deadline of §8.7 must act. - /// - /// The extractor runs on fill, the route gives a - /// test to script apart from the reply model. answers that - /// route, and it awaits a nobody ever sets, so nothing but - /// ends the conversation. - /// - public static InterruptionFixture StartWithHangingExtractor(string reply) - { - ScriptedChatClient replyClient = new(reply.Split(' ')); - HangingChatClient extractorClient = new(); - RoutingChatClientFactory chatClients = new RoutingChatClientFactory(replyClient).Route("fill", extractorClient); - - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ExtractorYaml); - CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(chatClients))["main"]; - ConversationSessionFactory factory = new( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, chatClients)); - - return new InterruptionFixture( - factory.Create(), - [replyClient, extractorClient], - TestContext.Current.CancellationToken); - } - - /// Runs one turn directly on the session, the way a host that never streams drives it. - public Task RunTurnAsync(string userInput) - { - return Session.RunTurnAsync(userInput, _cancellation.Token); - } - - /// Gets the extraction failure the last finished turn recorded, or . - public string? LastExtractionFailure => Session.LastTurn?.ExtractionFailure; - - /// Waits for the first update, interrupts with what the caller heard, and returns the finished turn. - public async Task InterruptAfterFirstUpdateAsync(string heard) - { - await _firstUpdate.Task.WaitAsync(_cancellation.Token).ConfigureAwait(false); - - // Section 7.1: the relay reports both the heard text and the played duration together. - _ = Session.Interrupt(heard, TimeSpan.FromMilliseconds(1820)); - _openGate(); - - await _run.WaitAsync(_cancellation.Token).ConfigureAwait(false); - Assert.NotNull(Session.LastTurn); - return Session.LastTurn; - } - - public async ValueTask DisposeAsync() - { - // A fact that stops before it interrupts must not leave the background task hanging. - _openGate(); - _ = _firstUpdate.TrySetResult(); - - try - { - await _run.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // The host token already ended the run. Nothing here is left to observe. - } - - _cancellation.Dispose(); - foreach (IDisposable disposable in _disposables) - { - disposable.Dispose(); - } - } - - private async Task PumpAsync() - { - await foreach (ChatResponseUpdate? update in Session.RunTurnStreamingAsync("hi", _cancellation.Token) - .ConfigureAwait(false)) - { - // The tool-call and tool-result updates carry no TextContent, so only the reply - // itself opens the window in which a test may interrupt. - if (update.Contents.OfType().Any(text => text.Text.Length > 0)) - { - _ = _firstUpdate.TrySetResult(); - } - } - } - } - - /// - /// Calls the one tool it is offered, then streams its final reply and blocks after the first - /// fragment until the test opens the gate. - /// - /// - /// covers the tool round, and - /// covers a gated round, but the finished-tool fact of this file needs both in one run: a tool - /// conversation whose result already arrived before the barge-in reaches the reply that follows it. - /// - private sealed class GatedToolThenReplyChatClient(string reply) : IChatClient - { - private const string ToolCallId = "conversation_1"; - - private readonly string[] _fragments = reply.Split(' '); - private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Lets every fragment after the first flow. - public void OpenGate() - { - _ = _gate.TrySetResult(); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - await Task.Yield(); - - bool answered = messages.Any(message => message.Contents.OfType().Any()); - string responseId = Guid.NewGuid().ToString("N"); - - if (!answered && options?.Tools?.OfType().FirstOrDefault() is { } tool) - { - yield return new ChatResponseUpdate( - ChatRole.Assistant, - [new FunctionCallContent(ToolCallId, tool.Name, new Dictionary(StringComparer.Ordinal))]) - { - ResponseId = responseId, - MessageId = responseId, - }; - - yield break; - } - - for (int index = 0; index < _fragments.Length; index++) - { - if (index == 1) - { - // The tool round already finished, so this is the round a barge-in interrupts. - await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - string text = index == 0 ? _fragments[index] : " " + _fragments[index]; - yield return new ChatResponseUpdate(ChatRole.Assistant, text) - { - ResponseId = responseId, - MessageId = responseId, - }; - } - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - List updates = []; - await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) - .ConfigureAwait(false)) - { - updates.Add(update); - } - - return updates.ToChatResponse(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - // Nothing to release. - } - } - - /// - /// Announces the tool in prose and calls it, both on one assistant message, then streams its - /// final reply and blocks after the first fragment until the test opens the gate. - /// - /// - /// yields a tool round that carries nothing but the - /// conversation. A real model routinely writes a line first — "Let me check that for you" — and puts the - /// conversation beside it on the same message. No other fake in this project can produce that shape, and - /// it is the shape the transcript rule of item 6a has to survive. - /// - private sealed class ProseBesideToolChatClient(string reply) : IChatClient - { - /// The line the model speaks before it calls the tool. - public const string Prose = "Let me check that for you"; - - private const string ToolCallId = "conversation_1"; - - private readonly string[] _fragments = reply.Split(' '); - private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Lets every fragment after the first flow. - public void OpenGate() - { - _ = _gate.TrySetResult(); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - await Task.Yield(); - - bool answered = messages.Any(message => message.Contents.OfType().Any()); - string responseId = Guid.NewGuid().ToString("N"); - - if (!answered && options?.Tools?.OfType().FirstOrDefault() is { } tool) - { - // The prose and the conversation ride one message, which is what a real model produces. - yield return new ChatResponseUpdate( - ChatRole.Assistant, - [ - new TextContent(Prose), - new FunctionCallContent(ToolCallId, tool.Name, new Dictionary(StringComparer.Ordinal)), - ]) - { - ResponseId = responseId, - MessageId = responseId, - }; - - yield break; - } - - for (int index = 0; index < _fragments.Length; index++) - { - if (index == 1) - { - // The tool round already finished, so this is the round a barge-in interrupts. - await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - string text = index == 0 ? _fragments[index] : " " + _fragments[index]; - yield return new ChatResponseUpdate(ChatRole.Assistant, text) - { - ResponseId = responseId, - MessageId = responseId, - }; - } - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - List updates = []; - await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) - .ConfigureAwait(false)) - { - updates.Add(update); - } - - return updates.ToChatResponse(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - // Nothing to release. - } - } - - /// - /// Streams its first reply to the end, then blocks the second one before it says a word. - /// - /// - /// This is the shape a held prompt produces: turn one finishes streaming, turn two starts at - /// once, and the vendor is still speaking turn one. A barge-in that lands in that window belongs - /// to turn one, and turn two has produced nothing for anyone to have heard. - /// - private sealed class SecondTurnGatedChatClient(string first, string second) : IChatClient - { - private readonly string _first = first; - private readonly string _second = second; - private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - private int _calls; - - /// Signals once the second turn's own model call is in flight and blocked. - public TaskCompletionSource SecondTurnStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Lets the second reply flow. - public void OpenGate() - { - _ = _gate.TrySetResult(); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - await Task.Yield(); - - int index = Interlocked.Increment(ref _calls); - string responseId = Guid.NewGuid().ToString("N"); - - if (index > 1) - { - _ = SecondTurnStarted.TrySetResult(); - - // Not WaitAsync(cancellationToken): a defect that cancelled this turn would then - // look like the pass this test is trying to disprove. - await _gate.Task.ConfigureAwait(false); - } - - foreach (string? word in (index == 1 ? _first : _second).Split(' ') - .Select((word, position) => position == 0 ? word : " " + word)) - { - yield return new ChatResponseUpdate(ChatRole.Assistant, word) - { - ResponseId = responseId, - MessageId = responseId, - }; - } - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - List updates = []; - await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) - .ConfigureAwait(false)) - { - updates.Add(update); - } - - return updates.ToChatResponse(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - // Nothing to release. - } - } - - /// - /// Answers with one call, then, once that call's result is in context, answers with that same - /// call again alongside a brand new one, both on a single message. - /// - /// - /// The runtime commits a round's tool results as one batch, so a round that never finishes - /// invoking surfaces no message at all, finished or not. Round one is a single call and finishes - /// on its own, so its result is already on the transcript when round two's message asks for two - /// conversations at once. That is the only way to reach the shape the multi-conversation fact needs: a message - /// with two calls where the finished-tool filter must trim rather than drop or keep it whole. - /// - private sealed class ParallelToolCallChatClient : IChatClient - { - /// The id of the conversation whose result arrives before the barge-in. - public const string FirstCallId = "conversation_a"; - - /// The id of the conversation still in flight when the barge-in arrives. - public const string SecondCallId = "conversation_b"; - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - await Task.Yield(); - - HashSet answered = messages - .SelectMany(message => message.Contents.OfType()) - .Select(result => result.CallId) - .ToHashSet(StringComparer.Ordinal); - - AIFunction tool = options?.Tools?.OfType().FirstOrDefault() - ?? throw new InvalidOperationException("The turn offers no tool to call."); - string responseId = Guid.NewGuid().ToString("N"); - - if (!answered.Contains(FirstCallId)) - { - // Round one. One call, so the runtime's batch commits alone and finishes at once. - yield return new ChatResponseUpdate( - ChatRole.Assistant, - [ - new FunctionCallContent( - FirstCallId, tool.Name, new Dictionary(StringComparer.Ordinal) { ["index"] = 1 }), - ]) - { - ResponseId = responseId, - MessageId = responseId, - }; - - yield break; - } - - // Round two. The already-answered conversation rides again beside the new one, on one message. - yield return new ChatResponseUpdate( - ChatRole.Assistant, - [ - new FunctionCallContent( - FirstCallId, tool.Name, new Dictionary(StringComparer.Ordinal) { ["index"] = 1 }), - new FunctionCallContent( - SecondCallId, tool.Name, new Dictionary(StringComparer.Ordinal) { ["index"] = 2 }), - ]) - { - ResponseId = responseId, - MessageId = responseId, - }; - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - List updates = []; - await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) - .ConfigureAwait(false)) - { - updates.Add(update); - } - - return updates.ToChatResponse(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - // Nothing to release. - } - } - - /// - /// Answers the first call to the one declared tool at once, and blocks the second call until the - /// run's own cancellation ends it. - /// - /// - /// The barge-in cancels the run's token, and nothing else ever completes the second call, so its - /// result never joins . tells - /// a test the first conversation already finished and the second is now the one in flight, which is the - /// instant a test must interrupt to pin the per-conversation-id rule. - /// - private sealed class PartiallyAnsweredToolFactory - { - /// Signals once the second call is in flight and blocked. - public TaskCompletionSource SecondConversationStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public AIFunction? Create(ToolConfiguration tool) - { - ArgumentNullException.ThrowIfNull(tool); - return AIFunctionFactory.Create(AnswerAsync, tool.Id, tool.Description ?? tool.Id); - } - - private async Task AnswerAsync(int index, CancellationToken cancellationToken) - { - if (index == 1) - { - return "50"; - } - - _ = SecondConversationStarted.TrySetResult(); - await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); - - // Unreachable: the delay above only ever ends in cancellation. - return string.Empty; - } - } - - /// - /// Reports that the extractor call is in flight, then holds it until the test lets it answer. - /// - /// - /// never answers at all, which is what §8.7's deadline needs. - /// This one has to answer, because the fact it serves is about what the turn commits *after* the - /// extractor returns: the window a late barge-in lands in is exactly the width of this conversation, and - /// nothing else in the turn loop opens one that a test can stand inside of. The gate is not read - /// through on purpose — the extractor runs on the host token so - /// a barge-in never cancels it, and a defect that did cancel it would end this wait quietly and - /// look like the pass this fake exists to disprove. - /// - private sealed class GatedExtractorChatClient : IChatClient - { - private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Gets a task that completes once the extractor's own model call is in flight. - public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Lets the extractor answer. - public void OpenGate() - { - _ = _gate.TrySetResult(); - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - _ = Started.TrySetResult(); - await _gate.Task.ConfigureAwait(false); - - // An empty document fills no slot, which is all this fake owes the writer order. - return new ChatResponse(new ChatMessage(ChatRole.Assistant, "{}")); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - // StateExtractor.ExtractAsync calls GetResponseAsync and never streams. - throw new NotSupportedException("The extractor calls GetResponseAsync, and never streams."); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - // Nothing to release. - } - } - - /// Answers nothing, ever. It stands in for the extractor model hanging past its deadline. - /// - /// §8.7's deadline is what ends this conversation, not the model, so awaits - /// a nobody ever sets. It returns only when its own - /// cancels. - /// - private sealed class HangingChatClient : IChatClient - { - private readonly TaskCompletionSource _neverSet = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(messages); - await _neverSet.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - // Unreachable: nothing ever completes _neverSet, so the wait above only ever cancels. - return new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty)); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - // StateExtractor.ExtractAsync calls GetResponseAsync and never streams. - throw new NotSupportedException("The extractor calls GetResponseAsync, and never streams."); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - ArgumentNullException.ThrowIfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - // Nothing to release. - } - } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs new file mode 100644 index 00000000..6b2d025f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionLifecycleTests.cs @@ -0,0 +1,88 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime; +using AgentCore.Domain; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// The reserved state slots, and what a completed or policy-less conversation allows. + public sealed class ConversationSessionLifecycleTests + { + // The reserved slots. + [Fact] + public async Task TheReservedSlots_CountTheFinishedTurnsAndReadTheInjectedClock() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + TestTimeProvider clock = new(); + ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock).Create(); + + Assert.Equal(0, session.State.TurnIndex); + Assert.Equal(0, session.State.ConversationDurationSeconds); + + clock.Advance(TimeSpan.FromSeconds(12.5)); + TurnResult first = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal(0, first.TurnIndex); + Assert.Equal(1, session.State.TurnIndex); + Assert.Equal(12.5, session.State.ConversationDurationSeconds); + + clock.Advance(TimeSpan.FromSeconds(7.5)); + TurnResult second = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); + + Assert.Equal(1, second.TurnIndex); + Assert.Equal(2, session.State.TurnIndex); + Assert.Equal(20, session.State.ConversationDurationSeconds); + } + + [Fact] + public async Task TheStage_ReachesTheStateDocumentSoAGuardReadsIt() + { + using SequencedChatClient reply = new("first reply."); + ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); + + _ = await session.RunTurnAsync("one", TestContext.Current.CancellationToken); + + Assert.Equal("close", session.Stage); + Assert.Equal("close", session.State.Stage); + Assert.Equal("close", session.State.Snapshot()[ReservedStateSlots.Stage]!.GetValue()); + } + + // A finished conversation. + [Fact] + public async Task ACompleteConversation_RejectsAFurtherTurn() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(SaidGoodbye); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + Assert.True(session.IsComplete); + + InvalidOperationException failure = await Assert.ThrowsAsync( + () => session.RunTurnAsync("hello again", TestContext.Current.CancellationToken)); + + Assert.Contains("close", failure.Message, StringComparison.Ordinal); + Assert.Equal(1, reply.Calls); + } + + [Fact] + public async Task ADocumentWithNoPolicy_RunsTheOneAgentAndNeverEnds() + { + using SequencedChatClient reply = new("I answered."); + ConversationSession session = Build(OneAgentYaml, reply, null).Create(); + + TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); + + Assert.Equal(string.Empty, session.Stage); + Assert.Equal(string.Empty, turn.StageBefore); + Assert.Equal(string.Empty, turn.StageAfter); + Assert.False(turn.IsTerminal); + Assert.False(session.IsComplete); + + _ = await session.RunTurnAsync("again", TestContext.Current.CancellationToken); + Assert.Equal(2, reply.Calls); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs new file mode 100644 index 00000000..4b7f0da1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionModelFaultTests.cs @@ -0,0 +1,67 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Transcript; +using AgentCore.Domain; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// A model that does not answer is a fault of the run, not of a tool: the fallback layer catches both, and only + /// a fault a tool threw spends a tool's budget. + /// + public sealed class ConversationSessionModelFaultTests + { + private const string ConversationId = "c-model-down"; + + /// The event id of Log.ToolBudgetSpent. + private const int ToolBudgetSpentEvent = 2; + + /// The event id of Log.TurnRunFaulted. + private const int TurnRunFaultedEvent = 28; + + [Fact(Timeout = 30_000)] + public async Task AModelThatIsDown_BeforeAnyToolCall_IsReportedAsARunFault() + { + using DownModelChatClient model = new(); + InMemoryAuditSink sink = new(); + RecordingObserver observed = new(); + using RecordingLoggerFactory logs = new(); + ConversationSession session = Build(model, sink, observed, logs.CreateLogger("session")); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal("the turn's run faulted, so it spoke the fallback. 503 from the model endpoint", turn.Failure); + Assert.DoesNotContain(sink.EventsOf(ConversationId), item => item.Kind == AuditEventKind.ToolFailed); + Assert.Contains(observed.Events, item => item.Kind == ConversationEventKind.RunFaulted); + Assert.DoesNotContain(observed.Events, item => item.Kind == ConversationEventKind.EmptyReply); + Assert.Empty(logs.Of(ToolBudgetSpentEvent)); + CapturedLine line = Assert.Single(logs.Of(TurnRunFaultedEvent)); + Assert.Equal(LogLevel.Error, line.Level); + _ = Assert.IsType(line.Exception); + } + + private static ConversationSession Build( + IChatClient model, InMemoryAuditSink sink, RecordingObserver observed, ILogger logger) + { + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(InterruptionSessions.NoToolYaml), + new AgentCompilationContext(new FakeChatClientFactory(model)))["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + extractor: null, + logger: logger, + observers: ConversationObservers.Standard(sink, logger, [observed])).Create(ConversationId); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs new file mode 100644 index 00000000..65d9e66d --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionPortTests.cs @@ -0,0 +1,40 @@ +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// D4: the inbound port. + public sealed class ConversationSessionPortTests + { + [Fact] + public async Task TheSession_SatisfiesTheInboundPort() + { + using SequencedChatClient reply = new("hello there.", "still here."); + using SequencedChatClient fill = new(StayingNull); + IConversationPort port = Assert.IsType(Build(PolicyYaml, reply, fill).Create("conversation-9"), exactMatch: false); + + TurnResult turn = await port.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // The relay shim and the SSE endpoint both drive this contract and reach past it for nothing. + Assert.Equal("conversation-9", port.ConversationId); + Assert.Equal("greeting", port.Stage); + Assert.False(port.IsComplete); + Assert.Same(turn, port.LastTurn); + + // A cut that lands after the turn ended is recorded, not ignored: the port amends the finished turn. + Assert.True(port.Cut(turn.TurnIndex, new TurnCut("nothing played", TimeSpan.Zero))); + + List updates = []; + await foreach (ChatResponseUpdate update in port.RunTurnStreamingAsync("still there?", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + Assert.Equal("still here.", string.Concat(updates.Select(update => update.Text))); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs new file mode 100644 index 00000000..b33f05c7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionRecutTests.cs @@ -0,0 +1,169 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// : a later account of what reached the user replaces the cut a turn + /// already took, while that turn is the newest one started (owner ruling E3). + /// + public sealed class ConversationSessionRecutTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task ARecutWhileTheCutTurnStillRuns_CommitsTheTurnWithTheRecutText() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("Hello ", "there")); + await using TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "hi"), origin: null, Ct); + + // Act + bool cut = false; + bool recut = false; + await foreach (ChatResponseUpdate update in run.Updates) + { + if (!cut && update.Text.Length > 0) + { + cut = session.Cut(run.TurnIndex, new TurnCut("Hello ", Played: null)); + recut = session.Recut(run.TurnIndex, new TurnCut("Hel", TimeSpan.FromMilliseconds(300))); + } + } + + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(cut); + Assert.True(recut); + Assert.Equal(["hi", "Hel"], Texts(session.Transcript)); + Assert.Equal(TimeSpan.FromMilliseconds(300), session.LastTurn!.Cut); + Assert.Equal(1, store.Appends); + Assert.Empty(store.Rewrites); + AuditEvent interrupted = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + Assert.Equal(AuditHash.OfText("Hel").Value, interrupted.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + } + + // TurnCutSlot remarks: a recut between the seal and the commit waits for the commit, which rewrites the reply + // the cut shaped. The store holds the append, so the turn stays sealed and uncommitted meanwhile. + [Fact] + public async Task ARecutWhileTheCutTurnsAppendIsInFlight_RewritesTheReplyOnceItLands() + { + // Arrange + ParkingConversationStore store = new(); + InMemoryAuditSink sink = new(); + ConversationSession session = Create(TurnScriptChatClient.Text("Hello ", "there"), store, sink); + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "hi"), origin: null, Ct); + TaskCompletionSource cutMade = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task reading = Task.Run( + async () => + { + await using (run) + { + await foreach (ChatResponseUpdate update in run.Updates) + { + if (!cutMade.Task.IsCompleted && update.Text.Length > 0) + { + _ = session.Cut(run.TurnIndex, new TurnCut("Hello ", Played: null)); + _ = cutMade.TrySetResult(); + } + } + } + }, + Ct); + await cutMade.Task.WaitAsync(Ct); + await store.Parked.WaitAsync(Ct); + + // Act + bool recut = session.Recut(run.TurnIndex, new TurnCut("Hel", TimeSpan.FromMilliseconds(300))); + store.Release(); + await reading.WaitAsync(Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(recut); + Assert.Equal(["hi", "Hel"], Texts(session.Transcript)); + Assert.Equal(["hi", "Hel"], Texts((await store.ReadForSessionAsync(session.ConversationId, Ct)).Select(row => row.Content))); + Assert.Equal(TimeSpan.FromMilliseconds(300), session.LastTurn!.Cut); + Assert.Equal( + [AuditHash.OfText("Hello").Value, AuditHash.OfText("Hel").Value], + sink.EventsOf(session.ConversationId) + .Where(item => item.Kind == AuditEventKind.ReplyInterrupted) + .Select(item => item.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256])); + } + + [Fact] + public async Task ARecutOfACommittedCutTurn_RewritesTheReply_AndAmendsTurnCompletedAgain() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("Hello ", "there")); + _ = await session.RunTurnAsync("hi", Ct); + _ = session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(100))); + + // Act + bool recut = session.Recut(0, new TurnCut("Hello there", TimeSpan.FromMilliseconds(900))); + bool cutAgain = session.Cut(0, new TurnCut("Hel", TimeSpan.FromMilliseconds(50))); + await session.FlushTranscriptAsync(); + + // Assert + Assert.True(recut); + Assert.False(cutAgain); + Assert.Equal(["hi", "Hello there"], Texts(session.Transcript)); + Assert.Equal("Hello there", session.LastTurn!.ReplyText); + Assert.Equal(TimeSpan.FromMilliseconds(900), session.LastTurn.Cut); + Assert.Equal(1, store.Appends); + Assert.Equal(2, store.Rewrites.Count); + IReadOnlyList events = sink.EventsOf(session.ConversationId); + AuditEvent completed = Assert.Single(events, item => item.Kind == AuditEventKind.TurnCompleted); + List amendments = [.. events.Where(item => item.Kind == AuditEventKind.ReplyInterrupted)]; + Assert.Equal(2, amendments.Count); + Assert.All(amendments, amendment => Assert.Equal(completed.EventId, amendment.AmendsEventId)); + Assert.Equal(AuditHash.OfText("Hello there").Value, amendments[^1].Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]); + Assert.Equal("900", amendments[^1].Payload[AuditPayloadKeys.DurationUntilInterruptMs]); + } + + [Fact] + public async Task ARecutOfATurnThatTookNoCut_ReportsFalseAndChangesNothing() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("Hello ", "there")); + _ = await session.RunTurnAsync("hi", Ct); + + // Act + bool recut = session.Recut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(100))); + await session.FlushTranscriptAsync(); + + // Assert + Assert.False(recut); + Assert.Equal(["hi", "Hello there"], Texts(session.Transcript)); + Assert.Empty(store.Rewrites); + Assert.DoesNotContain(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.ReplyInterrupted); + } + + // Owner ruling E3: once a later turn has started, an earlier turn's reply never changes. + [Fact] + public async Task ARecutOfACutTurnOnceTheNextTurnStarted_ReportsFalseAndChangesNothing() + { + // Arrange + (ConversationSession session, RecordingConversationStore store, _) = Create(TurnScriptChatClient.Sequence(["Hello there"], ["next"])); + _ = await session.RunTurnAsync("hi", Ct); + _ = session.Cut(0, new TurnCut("Hello", null)); + await using TurnRun next = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "and?"), origin: null, Ct); + + // Act + bool recut = session.Recut(0, new TurnCut("Hello there", TimeSpan.FromMilliseconds(900))); + await ReadToEndAsync(next, Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.False(recut); + Assert.Equal(["hi", "Hello", "and?", "next"], Texts(session.Transcript)); + _ = Assert.Single(store.Rewrites); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs new file mode 100644 index 00000000..ef16b84e --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionReminderTests.cs @@ -0,0 +1,86 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.State; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// The unfilled-slot reminder: when it rides the reply request, and when it does not. + public sealed class ConversationSessionReminderTests + { + // The reminder rides one request. + [Fact] + public async Task TheReminder_ReachesTheReplyAgentAndLeavesTheCallersMessageAlone() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(ReminderYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // The stage waits on two slots the caller supplies, and no writer has filled either yet. + Assert.Contains(UnfilledSlotReminder.OpenTag, reply.SystemText(0), StringComparison.Ordinal); + Assert.Contains( + "the machine model and the serial number", reply.SystemText(0), StringComparison.Ordinal); + + // The caller's utterance is the caller's. It goes to the model, to store 1 and to the + // extractor as it was spoken. + Assert.Equal("hi", reply.LastUserText(0)); + Assert.Equal("hi", session.Transcript[0].Text); + } + + [Fact] + public async Task TheReminder_DropsASlotOnceAWriterFillsIt() + { + using SequencedChatClient reply = new("hello there.", "still here."); + using SequencedChatClient fill = new(/*lang=json,strict*/ """{ "machineModel": "F85" }""", StayingNull); + ConversationSession session = Build(ReminderYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + _ = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); + + // Turn 2 still waits on the serial number, so the reminder survives and names only that one. + Assert.Contains( + "the machine model and the serial number", reply.SystemText(0), StringComparison.Ordinal); + Assert.Contains(UnfilledSlotReminder.OpenTag, reply.SystemText(1), StringComparison.Ordinal); + Assert.Contains("the serial number.", reply.SystemText(1), StringComparison.Ordinal); + Assert.DoesNotContain("the machine model", reply.SystemText(1), StringComparison.Ordinal); + } + + [Fact] + public async Task TheReminder_ReachesAStreamingTurnToo() + { + // The run is inside the async iterator, and an iterator restores its caller's execution + // context at every yield. A per-turn ambient value has to be opened again for each round or + // the streaming path silently loses the reminder the buffered path carries. + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(ReminderYaml, reply, fill).Create(); + + await foreach (ChatResponseUpdate _ in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) + { + } + + Assert.Contains(UnfilledSlotReminder.OpenTag, reply.SystemText(0), StringComparison.Ordinal); + Assert.Equal("hi", reply.LastUserText(0)); + } + + [Fact] + public async Task TheReminder_NeverAsksTheCallerForAnInferredFlag() + { + // config/local.yaml ships this shape: one boolean the extractor infers from the turn, read by + // the exit guard of the talking stage, carrying a default. Section 8.3 reminds on a slot that + // is "still null", and a default is never null, so nothing is owed and the caller is not asked. + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + string turnContext = reply.SystemText(0); + Assert.DoesNotContain(UnfilledSlotReminder.OpenTag, turnContext, StringComparison.Ordinal); + Assert.DoesNotContain("callerSaidGoodbye", turnContext, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs new file mode 100644 index 00000000..b48f35c4 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionStreamingTests.cs @@ -0,0 +1,142 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// The streaming path: what it carries while a turn runs, and which updates it drops. + public sealed class ConversationSessionStreamingTests + { + // Streaming. + [Fact] + public async Task Streaming_CarriesTheWholeReplyAndThenFinishesTheTurn() + { + using ScriptedChatClient reply = new("hello", " there."); + using SequencedChatClient fill = new(SaidGoodbye); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("goodbye", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + string text = string.Concat(updates.Select(update => update.Text)); + Assert.Equal("hello there.", text); + + Assert.NotNull(session.LastTurn); + Assert.Equal("hello there.", session.LastTurn.ReplyText); + Assert.Equal("close", session.LastTurn.StageAfter); + Assert.True(session.IsComplete); + } + + [Fact] + public async Task Streaming_MovesNothingUntilTheEnumerationCompletes() + { + using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; + using SequencedChatClient fill = new(SaidGoodbye); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + timeout.Token, TestContext.Current.CancellationToken); + + await using IAsyncEnumerator updates = session.RunTurnStreamingAsync("goodbye", linked.Token).GetAsyncEnumerator(linked.Token); + + // The model holds every fragment after the first, so this line runs mid-reply. + Assert.True(await updates.MoveNextAsync()); + Assert.Equal("hello", updates.Current.Text); + Assert.Null(session.LastTurn); + Assert.Equal("greeting", session.Stage); + Assert.Equal(0, session.State.TurnIndex); + + reply.OpenGate(); + while (await updates.MoveNextAsync()) + { + // Drain the rest of the reply. + } + + Assert.NotNull(session.LastTurn); + Assert.Equal("close", session.Stage); + Assert.Equal(1, session.State.TurnIndex); + } + + // The state document takes no lock, so a second turn waits for the first instead of running beside it. + [Fact] + public async Task ASecondTurn_WaitsForTheFirstOne_ThenRunsAfterIt() + { + using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + timeout.Token, TestContext.Current.CancellationToken); + + await using IAsyncEnumerator updates = session.RunTurnStreamingAsync("hi", linked.Token).GetAsyncEnumerator(linked.Token); + Assert.True(await updates.MoveNextAsync()); + + Task second = session.RunTurnAsync("hi again", linked.Token); + await Task.Delay(TimeSpan.FromMilliseconds(200), linked.Token); + Assert.False(second.IsCompleted); + + reply.OpenGate(); + while (await updates.MoveNextAsync()) + { + // Drain the rest of the reply. + } + + TurnResult after = await second; + Assert.Equal(1, after.TurnIndex); + Assert.Equal(2, session.State.TurnIndex); + } + + // Section 8.6: the update stream carries content only. + [Fact] + public async Task Streaming_DropsEveryUpdateThatCarriesNoContent() + { + using LifecycleChatClient reply = new("hello", " there."); + ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // AsAIAgent() yields 47 updates for 40 text fragments, and seven carry no content. The seam + // filters them once, so no host writes the filter again. The trailing update is the turn's + // committed ids (design section 6, step E5): it carries content but no text. + Assert.Equal(6, reply.Yielded); + Assert.Equal( + ["hello", " there."], + [.. updates.Where(update => !update.Contents.OfType().Any()).Select(update => update.Text)]); + Assert.NotNull(session.LastTurn); + Assert.Equal("hello there.", session.LastTurn.ReplyText); + } + + [Fact] + public async Task Streaming_KeepsTheUpdateThatCarriesAToolCall() + { + using ToolCallingChatClient reply = new("your order is on the way."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(ToolYaml, reply, fill, new StubToolBuilder(/*lang=json,strict*/ """{ "status": "shipped" }""").Create).Create(); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync( + "where is my order", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // A tool call and a tool result are content a host may show, so the filter keeps them. + Assert.Contains(updates, update => update.Contents.OfType().Any()); + Assert.Contains(updates, update => update.Text.Length > 0); + Assert.DoesNotContain(updates, update => update.Contents.Count == 0); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs new file mode 100644 index 00000000..cc962a52 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTestSupport.cs @@ -0,0 +1,201 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The YAML documents, the chat-client wiring, and the compare helper the split + /// ConversationSession test classes share. + /// + internal static class ConversationSessionTestSupport + { + internal const string PolicyYaml = + """ + apiVersion: agentcore/v1 + state: + callerSaidGoodbye: + type: boolean + default: false + writer: extractor + description: whether the caller said goodbye + brand: { type: string, writer: const, value: sole } + greetingTurns: + type: integer + default: 0 + writer: counter + increment: { "===": [ { var: stage }, "greeting" ] } + guards: + saidGoodbye: { var: callerSaidGoodbye } + extractor: + model: { ref: fill } + when: after_reply + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + - { id: closer, instructions: "close the conversation" } + entries: + main: + policy: + initial: greeting + stages: + - id: greeting + agent: greeter + to: [ { stage: close, when: saidGoodbye } ] + - id: close + agent: closer + terminal: true + """; + + internal const string ReminderYaml = + """ + apiVersion: agentcore/v1 + state: + machineModel: { type: string, writer: extractor, description: the machine model } + serialNumber: { type: string, writer: extractor, description: the serial number } + guards: + identified: + and: + - { "!!": [ { var: machineModel } ] } + - { "!!": [ { var: serialNumber } ] } + extractor: + model: { ref: fill } + when: after_reply + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + - { id: closer, instructions: "close the conversation" } + entries: + main: + policy: + initial: greeting + stages: + - id: greeting + agent: greeter + to: [ { stage: close, when: identified } ] + - id: close + agent: closer + terminal: true + """; + + internal const string TwoStagesYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "I am the greeter" } + - { id: closer, instructions: "I am the closer" } + entries: + main: + policy: + initial: greeting + stages: + - { id: greeting, agent: greeter, to: [ { stage: close } ] } + - { id: close, agent: closer, to: [ { stage: greeting } ] } + """; + + internal const string ToolYaml = + """ + apiVersion: agentcore/v1 + state: + orderStatus: { type: string, writer: tool, from: lookup_order.status } + callerSaidGoodbye: { type: boolean, default: false, writer: extractor } + shippedTurns: + type: integer + default: 0 + writer: counter + increment: { "===": [ { var: orderStatus }, "shipped" ] } + guards: + saidGoodbye: { var: callerSaidGoodbye } + extractor: + model: { ref: fill } + when: after_reply + tools: + - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller", tools: [ lookup_order ] } + - { id: closer, instructions: "close the conversation" } + entries: + main: + policy: + initial: greeting + stages: + - { id: greeting, agent: greeter, to: [ { stage: close, when: saidGoodbye } ] } + - { id: close, agent: closer, terminal: true } + """; + + internal const string OneAgentYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + entries: + main: + agent: only + """; + + internal const string StayingNull = /*lang=json,strict*/ """{ "callerSaidGoodbye": null }"""; + internal const string SaidGoodbye = /*lang=json,strict*/ """{ "callerSaidGoodbye": true }"""; + + internal static bool Contains(ChatMessage message, string text) + { + return message.Text.Contains(text, StringComparison.Ordinal); + } + + internal static ConversationSessionFactory Build( + string yaml, + IChatClient reply, + IChatClient? fill, + Func? tools = null, + TimeProvider? timeProvider = null, + ILogger? logger = null) + { + CompiledAgent compiled = Compile(yaml, reply, fill, tools); + RoutingChatClientFactory chatClients = new(reply); + if (fill is not null) + { + _ = chatClients.Route("fill", fill); + } + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, chatClients), + timeProvider, + logger); + } + + internal static CompiledAgent Compile(string yaml, IChatClient reply, IChatClient? fill, Func? tools) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + RoutingChatClientFactory chatClients = new(reply); + if (fill is not null) + { + _ = chatClients.Route("fill", fill); + } + + return ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) + { + Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), + })["main"]; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTests.cs deleted file mode 100644 index 8379b9ab..00000000 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTests.cs +++ /dev/null @@ -1,1131 +0,0 @@ -using AgentCore.TestSupport; -using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Ports; -using AgentCore.Application.Runtime; -using AgentCore.Application.State; -using AgentCore.Application.Tests.Fakes; -using Microsoft.Extensions.AI; -using Xunit; -using AgentCore.Domain; - -namespace AgentCore.Application.Tests.Runtime -{ - /// - /// The turn loop. One session runs one conversation, and one turn walks the writers in one fixed order. - /// - /// - /// Every test here runs offline. There is no network conversation and no API key anywhere in this file. - /// - public sealed class ConversationSessionTests - { - private const string PolicyYaml = - """ - apiVersion: agentcore/v1 - state: - callerSaidGoodbye: - type: boolean - default: false - writer: extractor - description: whether the caller said goodbye - brand: { type: string, writer: const, value: sole } - greetingTurns: - type: integer - default: 0 - writer: counter - increment: { "===": [ { var: stage }, "greeting" ] } - guards: - saidGoodbye: { var: callerSaidGoodbye } - extractor: - model: { ref: fill } - when: after_reply - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "greet the caller" } - - { id: closer, instructions: "close the conversation" } - entries: - main: - policy: - initial: greeting - stages: - - id: greeting - agent: greeter - to: [ { stage: close, when: saidGoodbye } ] - - id: close - agent: closer - terminal: true - """; - - private const string ReminderYaml = - """ - apiVersion: agentcore/v1 - state: - machineModel: { type: string, writer: extractor, description: the machine model } - serialNumber: { type: string, writer: extractor, description: the serial number } - guards: - identified: - and: - - { "!!": [ { var: machineModel } ] } - - { "!!": [ { var: serialNumber } ] } - extractor: - model: { ref: fill } - when: after_reply - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "greet the caller" } - - { id: closer, instructions: "close the conversation" } - entries: - main: - policy: - initial: greeting - stages: - - id: greeting - agent: greeter - to: [ { stage: close, when: identified } ] - - id: close - agent: closer - terminal: true - """; - - private const string TwoStagesYaml = - """ - apiVersion: agentcore/v1 - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "I am the greeter" } - - { id: closer, instructions: "I am the closer" } - entries: - main: - policy: - initial: greeting - stages: - - { id: greeting, agent: greeter, to: [ { stage: close } ] } - - { id: close, agent: closer, to: [ { stage: greeting } ] } - """; - - private const string ToolYaml = - """ - apiVersion: agentcore/v1 - state: - orderStatus: { type: string, writer: tool, from: lookup_order.status } - callerSaidGoodbye: { type: boolean, default: false, writer: extractor } - shippedTurns: - type: integer - default: 0 - writer: counter - increment: { "===": [ { var: orderStatus }, "shipped" ] } - guards: - saidGoodbye: { var: callerSaidGoodbye } - extractor: - model: { ref: fill } - when: after_reply - tools: - - { id: lookup_order, kind: builtin, uses: orders.read, description: "Look up an order by its id." } - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "greet the caller", tools: [ lookup_order ] } - - { id: closer, instructions: "close the conversation" } - entries: - main: - policy: - initial: greeting - stages: - - { id: greeting, agent: greeter, to: [ { stage: close, when: saidGoodbye } ] } - - { id: close, agent: closer, terminal: true } - """; - - private const string OneAgentYaml = - """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "I answer everything" } - entries: - main: - agent: only - """; - - private const string StayingNull = /*lang=json,strict*/ """{ "callerSaidGoodbye": null }"""; - private const string SaidGoodbye = /*lang=json,strict*/ """{ "callerSaidGoodbye": true }"""; - - // ------------------------------------------------------------------------------------------- - // One turn, end to end. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task ATurn_RunsTheAgentOfTheCurrentStageAndReportsWhatItDid() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-1"); - - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Assert.Equal("conversation-1", turn.ConversationId); - Assert.Equal(0, turn.TurnIndex); - Assert.Equal("greeting", turn.StageBefore); - Assert.Equal("greeting", turn.StageAfter); - Assert.Equal("hello there.", turn.ReplyText); - Assert.False(turn.IsTerminal); - Assert.Null(turn.ExtractionFailure); - Assert.Same(turn, session.LastTurn); - } - - [Fact] - public async Task ATurn_MakesTwoModelCalls_TheReplyAndTheExtractor() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // The extractor has no retry: one reply conversation, and one extractor call. - Assert.Equal(1, reply.Calls); - Assert.Equal(1, fill.Calls); - } - - [Fact] - public async Task TheTranscript_HoldsWhatTheCallerSaidAndWhatTheAgentAnswered() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Assert.Equal(2, session.Transcript.Count); - Assert.Equal(ChatRole.User, session.Transcript[0].Role); - Assert.Equal("hi", session.Transcript[0].Text); - Assert.Equal(ChatRole.Assistant, session.Transcript[1].Role); - Assert.Equal("hello there.", session.Transcript[1].Text); - } - - [Fact] - public async Task TheTranscript_HandsOutACopyAndNeverTheListTheSessionWritesTo() - { - using SequencedChatClient reply = new("hello there.", "and again."); - using SequencedChatClient fill = new(StayingNull, StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - IReadOnlyList taken = session.Transcript; - - _ = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); - - // A live view would grow behind the reader's back, and an amendment splicing the list under - // an enumeration would throw rather than return a torn conversation. What was handed out is - // one whole conversation as it stood at one instant, and the session went on without it. - Assert.Equal(2, taken.Count); - Assert.Equal(4, session.Transcript.Count); - Assert.NotSame(taken, session.Transcript); - } - - // ------------------------------------------------------------------------------------------- - // The reminder rides one request. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheReminder_ReachesTheReplyAgentAndLeavesTheCallersMessageAlone() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(ReminderYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // The stage waits on two slots the caller supplies, and no writer has filled either yet. - Assert.Contains(UnfilledSlotReminder.OpenTag, reply.SystemText(0), StringComparison.Ordinal); - Assert.Contains( - "the machine model and the serial number", reply.SystemText(0), StringComparison.Ordinal); - - // The caller's utterance is the caller's. It goes to the model, to store 1 and to the - // extractor as it was spoken. - Assert.Equal("hi", reply.LastUserText(0)); - Assert.Equal("hi", session.Transcript[0].Text); - } - - [Fact] - public async Task TheReminder_DropsASlotOnceAWriterFillsIt() - { - using SequencedChatClient reply = new("hello there.", "still here."); - using SequencedChatClient fill = new(/*lang=json,strict*/ """{ "machineModel": "F85" }""", StayingNull); - ConversationSession session = Build(ReminderYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - _ = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); - - // Turn 2 still waits on the serial number, so the reminder survives and names only that one. - Assert.Contains( - "the machine model and the serial number", reply.SystemText(0), StringComparison.Ordinal); - Assert.Contains(UnfilledSlotReminder.OpenTag, reply.SystemText(1), StringComparison.Ordinal); - Assert.Contains("the serial number.", reply.SystemText(1), StringComparison.Ordinal); - Assert.DoesNotContain("the machine model", reply.SystemText(1), StringComparison.Ordinal); - } - - [Fact] - public async Task TheReminder_ReachesAStreamingTurnToo() - { - // The run is inside the async iterator, and an iterator restores its caller's execution - // context at every yield. A per-turn ambient value has to be opened again for each round or - // the streaming path silently loses the reminder the buffered path carries. - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(ReminderYaml, reply, fill).Create(); - - await foreach (ChatResponseUpdate _ in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) - { - } - - Assert.Contains(UnfilledSlotReminder.OpenTag, reply.SystemText(0), StringComparison.Ordinal); - Assert.Equal("hi", reply.LastUserText(0)); - } - - [Fact] - public async Task TheReminder_NeverAsksTheCallerForAnInferredFlag() - { - // config/local.yaml ships this shape: one boolean the extractor infers from the turn, read by - // the exit guard of the talking stage, carrying a default. Section 8.3 reminds on a slot that - // is "still null", and a default is never null, so nothing is owed and the caller is not asked. - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - string turnContext = reply.SystemText(0); - Assert.DoesNotContain(UnfilledSlotReminder.OpenTag, turnContext, StringComparison.Ordinal); - Assert.DoesNotContain("callerSaidGoodbye", turnContext, StringComparison.Ordinal); - } - - // ------------------------------------------------------------------------------------------- - // The session owns the transcript, and the stage switches the agent. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheStage_SwitchesTheAgentAndTheTranscriptCarriesTheWholeConversation() - { - using SequencedChatClient reply = new("first reply.", "second reply."); - ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); - - _ = await session.RunTurnAsync("one", TestContext.Current.CancellationToken); - Assert.Equal("close", session.Stage); - - _ = await session.RunTurnAsync("two", TestContext.Current.CancellationToken); - Assert.Equal("greeting", session.Stage); - - // Each stage names its own agent, so the instructions change between the two requests. - Assert.Contains("I am the greeter", reply.Options[0]!.Instructions, StringComparison.Ordinal); - Assert.Contains("I am the closer", reply.Options[1]!.Instructions, StringComparison.Ordinal); - - // A session bound to one agent could not carry this. The turn loop owns the transcript, so - // the second agent reads the first turn. - Assert.Contains(reply.Requests[1], message => Contains(message, "one")); - Assert.Contains(reply.Requests[1], message => Contains(message, "first reply.")); - } - - // ------------------------------------------------------------------------------------------- - // The writers, in order. - // ------------------------------------------------------------------------------------------- - [Fact] - public void TheConstWriter_RunsOnceWhenTheSessionStarts() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - // A constant never changes, so it is written before the first turn and never again. - Assert.Equal("sole", session.State.Read("brand")!.GetValue()); - Assert.False(session.State.IsUnfilled("brand")); - } - - [Fact] - public async Task TheCounter_RunsBeforeTheTransitionAndReadsTheStageTheTurnSpokeIn() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(SaidGoodbye); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - TurnResult turn = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); - - // The turn spoke in greeting and the machine then moved to close. The counter rule reads - // stage === greeting, so a counter that ran after the transition would report zero. - Assert.Equal("greeting", turn.StageBefore); - Assert.Equal("close", turn.StageAfter); - Assert.Equal(1, session.State.Read("greetingTurns")!.GetValue()); - } - - [Fact] - public async Task TheToolWriter_RunsBeforeTheCounterThatReadsItsSlot() - { - using ToolCallingChatClient reply = new("your order is on the way."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(ToolYaml, reply, fill, new StubToolBuilder(/*lang=json,strict*/ """{ "status": "shipped" }""").Create).Create(); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - Assert.Equal(["lookup_order"], reply.Called); - Assert.Equal("shipped", session.State.Read("orderStatus")!.GetValue()); - - // The counter rule reads orderStatus. It is one only because the tool writer went first. - Assert.Equal(1, session.State.Read("shippedTurns")!.GetValue()); - Assert.Equal("greeting", turn.StageAfter); - } - - [Fact] - public async Task TheToolWriter_ReadsAResultTheToolAnsweredAsText() - { - using ToolCallingChatClient reply = new("your order is on the way."); - using SequencedChatClient fill = new(StayingNull); - StubToolBuilder tools = new(/*lang=json,strict*/ """{ "status": "shipped" }""", asText: true); - ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); - - _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - // A tool result has no declared shape, so a document answered as one string still resolves. - Assert.Equal("shipped", session.State.Read("orderStatus")!.GetValue()); - } - - [Fact] - public async Task TheExtractor_RunsBeforeTheTransitionThatReadsItsSlot() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(SaidGoodbye); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - TurnResult turn = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); - - // The guard reads callerSaidGoodbye, and only the extractor writes it. - Assert.True(session.State.Read("callerSaidGoodbye")!.GetValue()); - Assert.Equal("close", turn.StageAfter); - Assert.True(turn.IsTerminal); - Assert.True(session.IsComplete); - } - - [Fact] - public async Task TheExtractor_ReadsTheFinishedTurn() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // extractor.when: after_reply. The request holds what the caller said and what the agent - // answered, and the caller's message carries no reminder. - List request = fill.Requests[0]; - Assert.Contains(request, message => Contains(message, "hi")); - Assert.Contains(request, message => Contains(message, "hello there.")); - Assert.DoesNotContain(request, message => Contains(message, UnfilledSlotReminder.OpenTag)); - } - - [Fact] - public async Task TheExtractor_ReadsTheAgentsPreviousMessageBeforeTheFinishedTurn() - { - using SequencedChatClient reply = new("is it the CT900 or the CT900ENT?", "got it."); - using SequencedChatClient fill = new(StayingNull, StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("my belt slips", TestContext.Current.CancellationToken); - _ = await session.RunTurnAsync("the ENT one", TestContext.Current.CancellationToken); - - // Turn 1 has no earlier agent message, so the finished turn stands alone. - List first = [.. fill.Requests[0].Where(message => message.Role != ChatRole.System)]; - Assert.Equal([ChatRole.User, ChatRole.Assistant], first.Select(message => message.Role)); - - // Turn 2: the caller is answering a question. Measured 2026-09-02: without the question in - // view the extractor reads "the ENT one" and fills nothing; with it, it fills the machine. - List second = [.. fill.Requests[1].Where(message => message.Role != ChatRole.System)]; - Assert.Equal([ChatRole.Assistant, ChatRole.User, ChatRole.Assistant], second.Select(message => message.Role)); - Assert.Equal("is it the CT900 or the CT900ENT?", second[0].Text); - Assert.Equal("the ENT one", second[1].Text); - Assert.Equal("got it.", second[2].Text); - } - - [Fact] - public async Task AFailedExtraction_DoesNotDropTheTurn() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new("I am sorry, I cannot do that."); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // Section 8.7: the extractor never drops a conversation. The turn ends, and it carries the reason. - Assert.NotNull(turn.ExtractionFailure); - Assert.Equal("hello there.", turn.ReplyText); - Assert.Equal("greeting", turn.StageAfter); - Assert.True(session.State.IsUnfilled("callerSaidGoodbye")); - } - - [Fact] - public async Task ADocumentWithNoExtractor_RunsATurnAndReportsNoFailure() - { - using SequencedChatClient reply = new("first reply."); - ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); - - TurnResult turn = await session.RunTurnAsync("one", TestContext.Current.CancellationToken); - - Assert.Null(turn.ExtractionFailure); - Assert.Equal(1, reply.Calls); - } - - // ------------------------------------------------------------------------------------------- - // The reserved slots. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheReservedSlots_CountTheFinishedTurnsAndReadTheInjectedClock() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - TestTimeProvider clock = new(); - ConversationSession session = Build(PolicyYaml, reply, fill, timeProvider: clock).Create(); - - Assert.Equal(0, session.State.TurnIndex); - Assert.Equal(0, session.State.ConversationDurationSeconds); - - clock.Advance(TimeSpan.FromSeconds(12.5)); - TurnResult first = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - Assert.Equal(0, first.TurnIndex); - Assert.Equal(1, session.State.TurnIndex); - Assert.Equal(12.5, session.State.ConversationDurationSeconds); - - clock.Advance(TimeSpan.FromSeconds(7.5)); - TurnResult second = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); - - Assert.Equal(1, second.TurnIndex); - Assert.Equal(2, session.State.TurnIndex); - Assert.Equal(20, session.State.ConversationDurationSeconds); - } - - [Fact] - public async Task TheStage_ReachesTheStateDocumentSoAGuardReadsIt() - { - using SequencedChatClient reply = new("first reply."); - ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); - - _ = await session.RunTurnAsync("one", TestContext.Current.CancellationToken); - - Assert.Equal("close", session.Stage); - Assert.Equal("close", session.State.Stage); - Assert.Equal("close", session.State.Snapshot()[ReservedStateSlots.Stage]!.GetValue()); - } - - // ------------------------------------------------------------------------------------------- - // A finished conversation. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task ACompleteConversation_RejectsAFurtherTurn() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(SaidGoodbye); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - _ = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); - Assert.True(session.IsComplete); - - InvalidOperationException failure = await Assert.ThrowsAsync( - () => session.RunTurnAsync("hello again", TestContext.Current.CancellationToken)); - - Assert.Contains("close", failure.Message, StringComparison.Ordinal); - Assert.Equal(1, reply.Calls); - } - - [Fact] - public async Task ADocumentWithNoPolicy_RunsTheOneAgentAndNeverEnds() - { - using SequencedChatClient reply = new("I answered."); - ConversationSession session = Build(OneAgentYaml, reply, null).Create(); - - TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - - Assert.Equal(string.Empty, session.Stage); - Assert.Equal(string.Empty, turn.StageBefore); - Assert.Equal(string.Empty, turn.StageAfter); - Assert.False(turn.IsTerminal); - Assert.False(session.IsComplete); - - _ = await session.RunTurnAsync("again", TestContext.Current.CancellationToken); - Assert.Equal(2, reply.Calls); - } - - // ------------------------------------------------------------------------------------------- - // Streaming. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task Streaming_CarriesTheWholeReplyAndThenFinishesTheTurn() - { - using ScriptedChatClient reply = new("hello", " there."); - using SequencedChatClient fill = new(SaidGoodbye); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - List updates = []; - await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("goodbye", TestContext.Current.CancellationToken)) - { - updates.Add(update); - } - - string text = string.Concat(updates.Select(update => update.Text)); - Assert.Equal("hello there.", text); - - Assert.NotNull(session.LastTurn); - Assert.Equal("hello there.", session.LastTurn.ReplyText); - Assert.Equal("close", session.LastTurn.StageAfter); - Assert.True(session.IsComplete); - } - - [Fact] - public async Task Streaming_MovesNothingUntilTheEnumerationCompletes() - { - using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; - using SequencedChatClient fill = new(SaidGoodbye); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); - using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( - timeout.Token, TestContext.Current.CancellationToken); - - await using IAsyncEnumerator updates = session.RunTurnStreamingAsync("goodbye", linked.Token).GetAsyncEnumerator(linked.Token); - - // The model holds every fragment after the first, so this line runs mid-reply. - Assert.True(await updates.MoveNextAsync()); - Assert.Equal("hello", updates.Current.Text); - Assert.Null(session.LastTurn); - Assert.Equal("greeting", session.Stage); - Assert.Equal(0, session.State.TurnIndex); - - reply.OpenGate(); - while (await updates.MoveNextAsync()) - { - // Drain the rest of the reply. - } - - Assert.NotNull(session.LastTurn); - Assert.Equal("close", session.Stage); - Assert.Equal(1, session.State.TurnIndex); - } - - [Fact] - public async Task ASecondTurn_IsRejectedWhileTheFirstOneIsStillRunning() - { - using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); - using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( - timeout.Token, TestContext.Current.CancellationToken); - - await using IAsyncEnumerator updates = session.RunTurnStreamingAsync("hi", linked.Token).GetAsyncEnumerator(linked.Token); - Assert.True(await updates.MoveNextAsync()); - - // The state document takes no lock, so a second turn must fail rather than corrupt it. - InvalidOperationException failure = await Assert.ThrowsAsync( - () => session.RunTurnAsync("hi again", linked.Token)); - Assert.Contains("one turn at a time", failure.Message, StringComparison.Ordinal); - - reply.OpenGate(); - while (await updates.MoveNextAsync()) - { - // Drain the rest of the reply. - } - - Assert.NotNull(session.LastTurn); - } - - // ------------------------------------------------------------------------------------------- - // Section 8.7, last row: the run returns quietly with no text. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task AnEmptyReply_SpeaksTheFallbackAndReportsWhyTheTurnFailed() - { - using SequencedChatClient reply = new(" "); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // Request 41 goes out with no tools and the run returns with no exception. On a voice conversation - // that is silence, so the turn loop reads the reply rather than trusting the absence of a - // failure. - Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, turn.Failure); - Assert.Null(turn.InterruptedAfter); - - // The writers still ran, and the transcript holds what the caller heard. - Assert.Equal(1, session.State.TurnIndex); - Assert.Equal(1, session.State.Read("greetingTurns")!.GetValue()); - Assert.Equal(ConversationSession.FallbackReply, session.Transcript[^1].Text); - } - - [Fact] - public async Task AnEmptyReply_LeavesTheSessionReadyForTheNextTurn() - { - using SequencedChatClient reply = new("", "I am back."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - TurnResult first = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - TurnResult second = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); - - Assert.Equal(ConversationSession.FallbackReply, first.ReplyText); - Assert.Equal("I am back.", second.ReplyText); - Assert.Null(second.Failure); - Assert.Equal(1, second.TurnIndex); - } - - [Fact] - public async Task Streaming_SpeaksTheFallbackWhenTheRunProducesNoText() - { - using LifecycleChatClient reply = new(); - ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); - - List updates = []; - await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) - { - updates.Add(update); - } - - // The host hears the fallback rather than nothing at all: R2 makes a quiet run an ordinary - // successful one, and the spoken line leaves the seam the same way any reply does. - Assert.Equal([ConversationSession.FallbackReply], updates.Select(update => update.Text)); - Assert.NotNull(session.LastTurn); - Assert.Equal(ConversationSession.FallbackReply, session.LastTurn.ReplyText); - Assert.Equal(ConversationSession.EmptyReplyReason, session.LastTurn.Failure); - Assert.Equal("close", session.Stage); - } - - // ------------------------------------------------------------------------------------------- - // Section 8.7, sixth row: the 4th consecutive tool failure. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheFourthConsecutiveToolFailure_EndsTheTurnAndNeverKillsTheConversation() - { - using LoopingToolCallingChatClient reply = new(); - using SequencedChatClient fill = new(StayingNull); - ThrowingToolBuilder tools = new(); - ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - // MaximumConsecutiveErrorsPerRequest is 3, so the 4th failure throws out of the run. - Assert.Equal(4, tools.Calls); - Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.NotNull(turn.Failure); - Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); - Assert.Contains(ThrowingToolBuilder.Message, turn.Failure, StringComparison.Ordinal); - - // The writers ran in their fixed order, and the machine picked the stage of the next turn. - Assert.Equal(1, session.State.TurnIndex); - Assert.Equal("greeting", turn.StageAfter); - Assert.False(session.IsComplete); - - // The transcript holds the spoken line alone. A half-finished tool round would otherwise - // leave a conversation with no result behind, and the next turn would send it. - Assert.Equal(2, session.Transcript.Count); - Assert.Equal(ConversationSession.FallbackReply, session.Transcript[^1].Text); - } - - [Fact] - public async Task ARealDeclaredToolThatCannotReachItsEndpoint_EndsTheTurnPerRowSixAndKeepsTheConversation() - { - // The same row, reached the way a shipped tool actually reaches it. ThrowingToolBuilder - // throws straight at the framework; this goes through DeclaredTool, so the classification - // AuditingFunctionInvokingChatClient applies is what lets the fault out. Before the split - // every fault became a result and this budget could never fire at all. - using LoopingToolCallingChatClient reply = new(); - using SequencedChatClient fill = new(StayingNull); - UnreachableEndpointToolBuilder tools = new(); - ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - // MaximumConsecutiveErrorsPerRequest is 3, so the 4th failure throws out of the run. - Assert.Equal(4, tools.Calls); - Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); - Assert.NotNull(turn.Failure); - Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); - Assert.Contains(UnreachableEndpointToolBuilder.Message, turn.Failure, StringComparison.Ordinal); - - // The writers ran in their fixed order, the machine picked the stage of the next turn, and - // the conversation is still alive. - Assert.Equal(1, session.State.TurnIndex); - Assert.Equal("greeting", turn.StageAfter); - Assert.False(session.IsComplete); - } - - [Fact] - public async Task ARealDeclaredToolWhoseFaultTheModelCanAnswer_CompletesTheTurnNormally() - { - // The half that must not regress. The tool answers with an error result, the model reads it, - // and nothing about the turn is a failure: no budget spent, no fallback, a real reply. - using ToolCallingChatClient reply = new("That order is already closed."); - using SequencedChatClient fill = new(StayingNull); - RefusedRequestToolBuilder tools = new(); - ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); - - TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - Assert.Equal(1, tools.Calls); - Assert.Null(turn.Failure); - Assert.Equal("That order is already closed.", turn.ReplyText); - Assert.False(session.IsComplete); - } - - [Fact] - public async Task TheFourthConsecutiveToolFailure_LeavesTheSessionUnlocked() - { - using LoopingToolCallingChatClient reply = new(); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(ToolYaml, reply, fill, new ThrowingToolBuilder().Create).Create(); - - _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); - - // The _running flag went back to zero, so the next turn starts rather than throwing. - TurnResult second = await session.RunTurnAsync("try again", TestContext.Current.CancellationToken); - - Assert.Equal(1, second.TurnIndex); - Assert.Equal(ConversationSession.FallbackReply, second.ReplyText); - } - - [Fact] - public async Task Streaming_EndsTheTurnWhenTheFourthConsecutiveToolFailureThrows() - { - using LoopingToolCallingChatClient reply = new(); - using SequencedChatClient fill = new(StayingNull); - ThrowingToolBuilder tools = new(); - ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); - - List updates = []; - await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync( - "where is my order", TestContext.Current.CancellationToken)) - { - updates.Add(update); - } - - // The enumeration ends rather than throwing, so the host never sees the fault. - Assert.Equal(4, tools.Calls); - Assert.NotNull(session.LastTurn); - Assert.Equal(ConversationSession.FallbackReply, session.LastTurn.ReplyText); - Assert.StartsWith(ConversationSession.ToolFailureReason, session.LastTurn.Failure!, StringComparison.Ordinal); - Assert.Equal(1, session.State.TurnIndex); - } - - // ------------------------------------------------------------------------------------------- - // Item 6a: barge-in. The record holds what the caller heard. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task AnInterruption_RecordsTheHeardTextAndTheDurationTheRelayReported() - { - using ScriptedChatClient reply = new("I can help with that,", " and here is the long part.") - { - GateAfterFirstFragment = true, - }; - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); - using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( - timeout.Token, TestContext.Current.CancellationToken); - - await using IAsyncEnumerator updates = session.RunTurnStreamingAsync("hi", linked.Token).GetAsyncEnumerator(linked.Token); - Assert.True(await updates.MoveNextAsync()); - Assert.Equal("I can help with that,", updates.Current.Text); - - // Section 7.1 reports both values on the interrupt frame, at 1 ms. Nothing estimates either. - Assert.True(session.Interrupt("I can help", TimeSpan.FromMilliseconds(740))); - Assert.False(await updates.MoveNextAsync()); - - Assert.NotNull(session.LastTurn); - Assert.Equal("I can help", session.LastTurn.ReplyText); - Assert.Equal(TimeSpan.FromMilliseconds(740), session.LastTurn.InterruptedAfter); - Assert.Null(session.LastTurn.Failure); - - // The transcript holds the text the caller heard, not the text the model produced. - Assert.Equal("I can help", session.Transcript[^1].Text); - Assert.DoesNotContain(session.Transcript, message => Contains(message, "long part")); - } - - [Fact] - public async Task AnInterruption_RunsEveryWriterAndLeavesTheSessionReady() - { - using ScriptedChatClient reply = new("hello", " there.") { GateAfterFirstFragment = true }; - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30)); - using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( - timeout.Token, TestContext.Current.CancellationToken); - - await using (IAsyncEnumerator updates = session.RunTurnStreamingAsync("hi", linked.Token).GetAsyncEnumerator(linked.Token)) - { - Assert.True(await updates.MoveNextAsync()); - Assert.True(session.Interrupt("hel", TimeSpan.FromMilliseconds(120))); - - while (await updates.MoveNextAsync()) - { - // The stream ends on the interruption, so nothing arrives here. - } - } - - // The writers ran in their fixed order, and the counter read the stage the turn spoke in. - Assert.Equal(1, session.State.TurnIndex); - Assert.Equal(1, session.State.Read("greetingTurns")!.GetValue()); - Assert.Equal("greeting", session.Stage); - - // The session is not locked, so the caller who interrupted can speak next. - reply.OpenGate(); - TurnResult next = await session.RunTurnAsync("go on", linked.Token); - Assert.Equal(1, next.TurnIndex); - Assert.Null(next.InterruptedAfter); - } - - [Fact] - public async Task AnInterruption_NeverCutsATurnThatDoesNotStream() - { - // One audibility rule for both run shapes, decided by the owner on 2026-08-18: a run that - // has handed the host nothing cannot be the turn the caller was hearing. A turn that does - // not stream hands the host nothing until it returns, so a barge-in during it amends the - // turn that finished last — the caller was still hearing THAT reply — and the running turn - // finishes undisturbed. It used to be the other way: the frame cut the running turn and - // recorded heard text of a reply no host had ever received. - using ScriptedChatClient reply = new("hello", " there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - TurnResult first = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); - Assert.Equal("hello there.", first.ReplyText); - - // The second turn parks inside its model call, so the frame lands while it runs. - reply.GateAfterFirstFragment = true; - Task running = session.RunTurnAsync("go on", TestContext.Current.CancellationToken); - Assert.True(session.Interrupt("hello", TimeSpan.FromMilliseconds(90))); - - // The frame amended the FIRST turn — the only reply the caller could have been hearing — - // and the record is readable before the second turn even finishes. - TurnResult? amended = session.LastTurn; - Assert.NotNull(amended); - Assert.Equal(0, amended.TurnIndex); - Assert.Equal("hello", amended.ReplyText); - Assert.Equal(TimeSpan.FromMilliseconds(90), amended.InterruptedAfter); - - reply.OpenGate(); - TurnResult second = await running; - - // The running turn was never cut: it spoke its whole reply, uninterrupted. - Assert.Equal("hello there.", second.ReplyText); - Assert.Null(second.InterruptedAfter); - Assert.Null(second.Failure); - Assert.Equal(2, session.State.TurnIndex); - } - - [Fact] - public void AnInterruptionWithNoRunningTurn_ReportsFalseAndChangesNothing() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(PolicyYaml, reply, fill).Create(); - - // A frame that arrives after the turn ended must not drop the conversation, so it answers false. - Assert.False(session.Interrupt("nothing played", TimeSpan.FromMilliseconds(5))); - - Assert.Null(session.LastTurn); - Assert.Equal(0, session.State.TurnIndex); - Assert.Empty(session.Transcript); - } - - // ------------------------------------------------------------------------------------------- - // Section 8.6: the update stream carries content only. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task Streaming_DropsEveryUpdateThatCarriesNoContent() - { - using LifecycleChatClient reply = new("hello", " there."); - ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); - - List updates = []; - await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("hi", TestContext.Current.CancellationToken)) - { - updates.Add(update); - } - - // AsAIAgent() yields 47 updates for 40 text fragments, and seven carry no content. The seam - // filters them once, so no host writes the filter again. - Assert.Equal(6, reply.Yielded); - Assert.Equal(["hello", " there."], [.. updates.Select(update => update.Text)]); - Assert.NotNull(session.LastTurn); - Assert.Equal("hello there.", session.LastTurn.ReplyText); - } - - [Fact] - public async Task Streaming_KeepsTheUpdateThatCarriesAToolCall() - { - using ToolCallingChatClient reply = new("your order is on the way."); - using SequencedChatClient fill = new(StayingNull); - ConversationSession session = Build(ToolYaml, reply, fill, new StubToolBuilder(/*lang=json,strict*/ """{ "status": "shipped" }""").Create).Create(); - - List updates = []; - await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync( - "where is my order", TestContext.Current.CancellationToken)) - { - updates.Add(update); - } - - // A tool call and a tool result are content a host may show, so the filter keeps them. - Assert.Contains(updates, update => update.Contents.OfType().Any()); - Assert.Contains(updates, update => update.Text.Length > 0); - Assert.DoesNotContain(updates, update => update.Contents.Count == 0); - } - - // ------------------------------------------------------------------------------------------- - // D4: the inbound port. - // ------------------------------------------------------------------------------------------- - [Fact] - public async Task TheSession_SatisfiesTheInboundPort() - { - using SequencedChatClient reply = new("hello there.", "still here."); - using SequencedChatClient fill = new(StayingNull); - IConversationPort port = Assert.IsType(Build(PolicyYaml, reply, fill).Create("conversation-9"), exactMatch: false); - - TurnResult turn = await port.RunTurnAsync("hi", TestContext.Current.CancellationToken); - - // The relay shim and the SSE endpoint both drive this contract and reach past it for nothing. - Assert.Equal("conversation-9", port.ConversationId); - Assert.Equal("greeting", port.Stage); - Assert.False(port.IsComplete); - Assert.Same(turn, port.LastTurn); - - // A frame that lands after the turn ended is recorded, not ignored. The vendor paces the - // audio, so the caller was still hearing this reply long after the model stopped producing - // it, and the port amends the finished turn. See the remarks on ConversationSession.Interrupt. - Assert.True(port.Interrupt("nothing played", TimeSpan.Zero)); - - List updates = []; - await foreach (ChatResponseUpdate update in port.RunTurnStreamingAsync("still there?", TestContext.Current.CancellationToken)) - { - updates.Add(update); - } - - Assert.Equal("still here.", string.Concat(updates.Select(update => update.Text))); - } - - // ------------------------------------------------------------------------------------------- - // The factory. - // ------------------------------------------------------------------------------------------- - [Fact] - public void TheFactory_MakesAConversationIdWhenTheHostGivesNone() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - ConversationSessionFactory factory = Build(PolicyYaml, reply, fill); - - ConversationSession first = factory.Create(); - ConversationSession second = factory.Create(); - - Assert.NotEmpty(first.ConversationId); - Assert.NotEqual(first.ConversationId, second.ConversationId); - } - - [Fact] - public async Task TheFactory_GivesEachConversationItsOwnStateAndItsOwnMachine() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(SaidGoodbye, StayingNull); - ConversationSessionFactory factory = Build(PolicyYaml, reply, fill); - - ConversationSession first = factory.Create("conversation-1"); - ConversationSession second = factory.Create("conversation-2"); - - _ = await first.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); - - // One machine belongs to one conversation. The first conversation ended, and the second one has not started. - Assert.True(first.IsComplete); - Assert.False(second.IsComplete); - Assert.Equal("greeting", second.Stage); - Assert.Empty(second.Transcript); - Assert.NotSame(first.State, second.State); - - // Both conversations share the one compiled agent. T44 and rule 16. - Assert.Same(first.Compiled, second.Compiled); - } - - [Fact] - public void TheFactory_BuildsNoExtractorWhenTheDocumentDeclaresNone() - { - using SequencedChatClient reply = new("hello there."); - CompiledAgent compiled = Compile(TwoStagesYaml, reply, null, null); - - Assert.Null(ConversationSessionFactory.CreateExtractor(compiled, new FakeChatClientFactory(reply))); - } - - [Fact] - public void TheFactory_BuildsTheExtractorTheDocumentDeclares() - { - using SequencedChatClient reply = new("hello there."); - using SequencedChatClient fill = new(StayingNull); - CompiledAgent compiled = Compile(PolicyYaml, reply, fill, null); - - StateExtractor? extractor = ConversationSessionFactory.CreateExtractor( - compiled, - new RoutingChatClientFactory(reply).Route("fill", fill)); - - Assert.NotNull(extractor); - Assert.Equal(["callerSaidGoodbye"], extractor.SlotNames); - } - - // ------------------------------------------------------------------------------------------- - // Helpers. - // ------------------------------------------------------------------------------------------- - private static bool Contains(ChatMessage message, string text) - { - return message.Text.Contains(text, StringComparison.Ordinal); - } - - private static ConversationSessionFactory Build( - string yaml, - IChatClient reply, - IChatClient? fill, - Func? tools = null, - TimeProvider? timeProvider = null) - { - CompiledAgent compiled = Compile(yaml, reply, fill, tools); - RoutingChatClientFactory chatClients = new(reply); - if (fill is not null) - { - _ = chatClients.Route("fill", fill); - } - - return new ConversationSessionFactory( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, chatClients), - timeProvider); - } - - private static CompiledAgent Compile(string yaml, IChatClient reply, IChatClient? fill, Func? tools) - { - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); - RoutingChatClientFactory chatClients = new(reply); - if (fill is not null) - { - _ = chatClients.Route("fill", fill); - } - - return ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(chatClients) - { - Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), - })["main"]; - } - } -} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs new file mode 100644 index 00000000..06bacad2 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionToolFailureTests.cs @@ -0,0 +1,127 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Section 8.7, sixth row: the 4th consecutive tool failure. + public sealed class ConversationSessionToolFailureTests + { + [Fact] + public async Task TheFourthConsecutiveToolFailure_EndsTheTurnAndNeverKillsTheConversation() + { + using LoopingToolCallingChatClient reply = new(); + using SequencedChatClient fill = new(StayingNull); + ThrowingToolBuilder tools = new(); + ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + // MaximumConsecutiveErrorsPerRequest is 3, so the 4th failure throws out of the run. + Assert.Equal(4, tools.Calls); + Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); + Assert.NotNull(turn.Failure); + Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); + Assert.Contains(ThrowingToolBuilder.Message, turn.Failure, StringComparison.Ordinal); + + // The writers ran in their fixed order, and the machine picked the stage of the next turn. + Assert.Equal(1, session.State.TurnIndex); + Assert.Equal("greeting", turn.StageAfter); + Assert.False(session.IsComplete); + + // Engine design section 3, W07: a tool fault keeps the user message, the three rounds whose + // result arrived, and the fallback. The 4th call never got a result, so it is dropped. + Assert.Equal("where is my order", session.Transcript[0].Text); + Assert.Equal(3, session.Transcript.SelectMany(message => message.Contents).OfType().Count()); + Assert.Equal(3, session.Transcript.SelectMany(message => message.Contents).OfType().Count()); + Assert.Equal(ConversationSession.FallbackReply, session.Transcript[^1].Text); + } + + [Fact] + public async Task ARealDeclaredToolThatCannotReachItsEndpoint_EndsTheTurnPerRowSixAndKeepsTheConversation() + { + // The same row, reached the way a shipped tool actually reaches it. ThrowingToolBuilder + // throws straight at the framework; this goes through DeclaredTool, so the classification + // AuditingFunctionInvokingChatClient applies is what lets the fault out. + using LoopingToolCallingChatClient reply = new(); + using SequencedChatClient fill = new(StayingNull); + UnreachableEndpointToolBuilder tools = new(); + ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + // MaximumConsecutiveErrorsPerRequest is 3, so the 4th failure throws out of the run. + Assert.Equal(4, tools.Calls); + Assert.Equal(ConversationSession.FallbackReply, turn.ReplyText); + Assert.NotNull(turn.Failure); + Assert.StartsWith(ConversationSession.ToolFailureReason, turn.Failure, StringComparison.Ordinal); + Assert.Contains(UnreachableEndpointToolBuilder.Message, turn.Failure, StringComparison.Ordinal); + + // The writers ran in their fixed order, the machine picked the stage of the next turn, and + // the conversation is still alive. + Assert.Equal(1, session.State.TurnIndex); + Assert.Equal("greeting", turn.StageAfter); + Assert.False(session.IsComplete); + } + + [Fact] + public async Task ARealDeclaredToolWhoseFaultTheModelCanAnswer_CompletesTheTurnNormally() + { + // The half that must not regress. The tool answers with an error result, the model reads it, + // and nothing about the turn is a failure: no budget spent, no fallback, a real reply. + using ToolCallingChatClient reply = new("That order is already closed."); + using SequencedChatClient fill = new(StayingNull); + RefusedRequestToolBuilder tools = new(); + ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + Assert.Equal(1, tools.Calls); + Assert.Null(turn.Failure); + Assert.Equal("That order is already closed.", turn.ReplyText); + Assert.False(session.IsComplete); + } + + [Fact] + public async Task TheFourthConsecutiveToolFailure_LeavesTheSessionUnlocked() + { + using LoopingToolCallingChatClient reply = new(); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(ToolYaml, reply, fill, new ThrowingToolBuilder().Create).Create(); + + _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + // The _running flag went back to zero, so the next turn starts rather than throwing. + TurnResult second = await session.RunTurnAsync("try again", TestContext.Current.CancellationToken); + + Assert.Equal(1, second.TurnIndex); + Assert.Equal(ConversationSession.FallbackReply, second.ReplyText); + } + + [Fact] + public async Task Streaming_EndsTheTurnWhenTheFourthConsecutiveToolFailureThrows() + { + using LoopingToolCallingChatClient reply = new(); + using SequencedChatClient fill = new(StayingNull); + ThrowingToolBuilder tools = new(); + ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); + + List updates = []; + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync( + "where is my order", TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // The enumeration ends rather than throwing, so the host never sees the fault. + Assert.Equal(4, tools.Calls); + Assert.NotNull(session.LastTurn); + Assert.Equal(ConversationSession.FallbackReply, session.LastTurn.ReplyText); + Assert.StartsWith(ConversationSession.ToolFailureReason, session.LastTurn.Failure!, StringComparison.Ordinal); + Assert.Equal(1, session.State.TurnIndex); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs new file mode 100644 index 00000000..585193e6 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTranscriptTests.cs @@ -0,0 +1,108 @@ +using AgentCore.Application.Runtime; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The turn loop's one-turn contract: what a turn reports, and what the transcript it owns holds + /// and hands out. + /// + public sealed class ConversationSessionTranscriptTests + { + // One turn, end to end. + [Fact] + public async Task ATurn_RunsTheAgentOfTheCurrentStageAndReportsWhatItDid() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create("conversation-1"); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal("conversation-1", turn.ConversationId); + Assert.Equal(0, turn.TurnIndex); + Assert.Equal("greeting", turn.StageBefore); + Assert.Equal("greeting", turn.StageAfter); + Assert.Equal("hello there.", turn.ReplyText); + Assert.False(turn.IsTerminal); + Assert.Null(turn.ExtractionFailure); + Assert.Same(turn, session.LastTurn); + } + + [Fact] + public async Task ATurn_MakesTwoModelCalls_TheReplyAndTheExtractor() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // The extractor has no retry: one reply conversation, and one extractor call. + Assert.Equal(1, reply.Calls); + Assert.Equal(1, fill.Calls); + } + + [Fact] + public async Task TheTranscript_HoldsWhatTheCallerSaidAndWhatTheAgentAnswered() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + Assert.Equal(2, session.Transcript.Count); + Assert.Equal(ChatRole.User, session.Transcript[0].Role); + Assert.Equal("hi", session.Transcript[0].Text); + Assert.Equal(ChatRole.Assistant, session.Transcript[1].Role); + Assert.Equal("hello there.", session.Transcript[1].Text); + } + + [Fact] + public async Task TheTranscript_HandsOutACopyAndNeverTheListTheSessionWritesTo() + { + using SequencedChatClient reply = new("hello there.", "and again."); + using SequencedChatClient fill = new(StayingNull, StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + IReadOnlyList taken = session.Transcript; + + _ = await session.RunTurnAsync("still there?", TestContext.Current.CancellationToken); + + // A live view would grow behind the reader's back, and an amendment splicing the list under + // an enumeration would throw rather than return a torn conversation. What was handed out is + // one whole conversation as it stood at one instant, and the session went on without it. + Assert.Equal(2, taken.Count); + Assert.Equal(4, session.Transcript.Count); + Assert.NotSame(taken, session.Transcript); + } + + // The session owns the transcript, and the stage switches the agent. + [Fact] + public async Task TheStage_SwitchesTheAgentAndTheTranscriptCarriesTheWholeConversation() + { + using SequencedChatClient reply = new("first reply.", "second reply."); + ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); + + _ = await session.RunTurnAsync("one", TestContext.Current.CancellationToken); + Assert.Equal("close", session.Stage); + + _ = await session.RunTurnAsync("two", TestContext.Current.CancellationToken); + Assert.Equal("greeting", session.Stage); + + // Each stage names its own agent, so the instructions change between the two requests. + Assert.Contains("I am the greeter", reply.Options[0]!.Instructions, StringComparison.Ordinal); + Assert.Contains("I am the closer", reply.Options[1]!.Instructions, StringComparison.Ordinal); + + // A session bound to one agent could not carry this. The turn loop owns the transcript, so + // the second agent reads the first turn. + Assert.Contains(reply.Requests[1], message => Contains(message, "one")); + Assert.Contains(reply.Requests[1], message => Contains(message, "first reply.")); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs new file mode 100644 index 00000000..ec8a679c --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnHistoryTests.cs @@ -0,0 +1,135 @@ +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// What a turn's model reads when the turn starts beside the one before it. The rule is LiveKit's, which the + /// voice port follows: the next reply starts only after the previous one is done and saved + /// (agent_activity.py:2782). So a turn's model input holds every turn committed before it was admitted, + /// and a start that gives up waiting changes nothing. + /// + public sealed class ConversationSessionTurnHistoryTests + { + private static readonly TimeSpan Stuck = TimeSpan.FromSeconds(10); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Probe P7e (docs/probes/voice-engine/engine/REPORT.md, F1): the next turn's start reads the store while the + // turn before it is cut and commits. The cut turn ends only once the store answered its append + // (ConversationTurnCommit.RefusedAsync), so the append lands before that read returns. + [Fact] + public async Task NextTurnStartsWhileThePreviousTurnCommits_ItsModelReadsThePreviousTurn() + { + // Arrange + HoldingConversationStore store = new(); + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["first reply"], ["second reply"]); + model.GateBeforeCall = 0; + ConversationSession session = Create(model, store); + TurnRun first = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "q0"), origin: null, Ct); + Task firstRead = ReadToEndAsync(first, Ct); + await model.Gated.Task.WaitAsync(Stuck, Ct); + + // Act + store.HoldGets(); + store.HoldAppends(); + Task starting = session.StartTurnAsync(new ChatMessage(ChatRole.User, "q1"), origin: null, Ct); + await store.GetHeld.Task.WaitAsync(Stuck, Ct); + Assert.True(session.Cut(first.TurnIndex, new TurnCut(string.Empty, null))); + await store.AppendHeld.Task.WaitAsync(Stuck, Ct); + store.ReleaseAppends(); + await firstRead.WaitAsync(Stuck, Ct); + await first.DisposeAsync(); + + store.ReleaseGets(); + await using TurnRun second = await starting.WaitAsync(Stuck, Ct); + await ReadToEndAsync(second, Ct).WaitAsync(Stuck, Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal(["q0", "q1"], Texts(model.Requests[1].Where(message => message.Role != ChatRole.System))); + Assert.Equal(["q0", "q1", "second reply"], Texts(session.Transcript)); + } + + // Probe P7a/P7c, the other order: the store read begun before the turn was admitted misses the turn that + // committed meanwhile, since the session's words moved under it, and the store also holds a row written from + // outside any turn. The admitted turn reads both. + [Fact] + public async Task StoreReadBeforeAdmissionMissesTheTurnThatCommitted_TheAdmittedTurnStillReadsEveryRow() + { + // Arrange + HoldingConversationStore store = new(); + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["a0"], ["a1"], ["a2"]); + model.GateBeforeCall = 1; + ConversationSession session = Create(model, store); + _ = await session.RunTurnAsync("q0", Ct); + TurnRun running = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "q1"), origin: null, Ct); + Task runningRead = ReadToEndAsync(running, Ct); + await model.Gated.Task.WaitAsync(Stuck, Ct); + _ = await ((IConversationStore)store).AppendMessageAsync(session.ConversationId, new ChatMessage(ChatRole.User, "a note"), Ct); + + // Act + store.HoldGets(); + store.HoldAppends(); + Task starting = session.StartTurnAsync(new ChatMessage(ChatRole.User, "q2"), origin: null, Ct); + await store.GetHeld.Task.WaitAsync(Stuck, Ct); + _ = model.Release.TrySetResult(); + await store.AppendHeld.Task.WaitAsync(Stuck, Ct); + store.ReleaseAppends(); + await runningRead.WaitAsync(Stuck, Ct); + await running.DisposeAsync(); + + store.ReleaseGets(); + await store.SessionRead.Task.WaitAsync(Stuck, Ct); + await using TurnRun third = await starting.WaitAsync(Stuck, Ct); + await ReadToEndAsync(third, Ct).WaitAsync(Stuck, Ct); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal( + ["q0", "a0", "a note", "q1", "a1", "q2"], + Texts(model.Requests[2].Where(message => message.Role != ChatRole.System))); + } + + // Probe F1, second cause: a start that gives up waiting for a running turn must not re-read the store into the + // session. The next admitted turn still catches up with what was written from outside. + [Fact] + public async Task AStartAbandonedWhileATurnRuns_ChangesNoWords_AndTheNextTurnCatchesUp() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["a0"], ["a1"], ["a2"]); + model.GateBeforeCall = 1; + (ConversationSession session, RecordingConversationStore store, _) = Create(model); + _ = await session.RunTurnAsync("q0", Ct); + TurnRun running = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "q1"), origin: null, Ct); + Task runningRead = ReadToEndAsync(running, Ct); + await model.Gated.Task.WaitAsync(Stuck, Ct); + _ = await ((IConversationStore)store).AppendMessageAsync(session.ConversationId, new ChatMessage(ChatRole.User, "a note"), Ct); + + // Act + using (CancellationTokenSource giveUp = CancellationTokenSource.CreateLinkedTokenSource(Ct)) + { + giveUp.CancelAfter(TimeSpan.FromMilliseconds(200)); + _ = await Assert.ThrowsAnyAsync( + () => session.StartTurnAsync(new ChatMessage(ChatRole.User, "q2"), origin: null, giveUp.Token)); + } + + List afterRefusal = Texts(session.Transcript); + + _ = model.Release.TrySetResult(); + await runningRead.WaitAsync(Stuck, Ct); + await running.DisposeAsync(); + _ = await session.RunTurnAsync("q2", Ct); + + // Assert + Assert.Equal(["q0", "a0"], afterRefusal); + Assert.Equal( + ["q0", "a0", "a note", "q1", "a1", "q2"], + Texts(model.Requests[2].Where(message => message.Role != ChatRole.System))); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs new file mode 100644 index 00000000..f623580f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionTurnRunTests.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Conversation; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionCutTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The a started turn hands back: how its disposal and its one read hold and free the + /// conversation (owner ruling 2026-09-23). + /// + public sealed class ConversationSessionTurnRunTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // TurnRun.DisposeAsync is safe to call more than once: a second disposal of a run nobody read frees nothing, + // so the turn that started after the first disposal still holds the conversation. + [Fact] + public async Task DisposingAnUnreadRunTwice_LeavesTheTurnStartedSinceHoldingTheConversation() + { + // Arrange + (ConversationSession session, _, _) = Create(TurnScriptChatClient.Text("reply")); + TurnRun unread = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "one"), origin: null, Ct); + await unread.DisposeAsync(); + await using TurnRun running = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + + // Act + await unread.DisposeAsync(); + + // Assert: a third turn still waits for "two", so it gives up rather than start. + using CancellationTokenSource giveUp = CancellationTokenSource.CreateLinkedTokenSource(Ct); + giveUp.CancelAfter(TimeSpan.FromMilliseconds(200)); + _ = await Assert.ThrowsAnyAsync( + () => session.StartTurnAsync(new ChatMessage(ChatRole.User, "three"), origin: null, giveUp.Token)); + } + + // TurnRun remarks: a run disposed unread commits nothing, and its reply can no longer be read. + [Fact] + public async Task ARunDisposedUnread_RefusesToBeRead_AndNeverCallsTheModel() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Text("reply"); + (ConversationSession session, RecordingConversationStore store, _) = Create(model); + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "one"), origin: null, Ct); + await run.DisposeAsync(); + + // Act + _ = await Assert.ThrowsAsync(async () => + { + await foreach (ChatResponseUpdate _ in run.Updates) + { + } + }); + await session.FlushTranscriptAsync(); + + // Assert + Assert.Equal(0, model.Calls); + Assert.Equal(0, store.Appends); + } + + // BeginTurn opens the span before the caller asks for the reply, so a run disposed unread still closes it, + // with the outcome and the duration a stopped turn gets (audit finding F2, docs/probes/r2/audit/AUDIT.md). + [Fact] + public async Task ARunDisposedUnread_StillEndsItsSpan_WithAnInterruptedOutcomeAndADuration() + { + // Arrange + List spans = []; + using ActivityListener listener = ListenToAgentCoreTelemetry(spans); + + List durations = []; + using MeterListener meter = new() + { + InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal) + && string.Equals(instrument.Name, "agentcore.turn.duration", StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(instrument); + } + }, + }; + meter.SetMeasurementEventCallback((_, measurement, _, _) => + { + lock (durations) + { + durations.Add(measurement); + } + }); + meter.Start(); + + (ConversationSession session, _, _) = Create(TurnScriptChatClient.Text("reply")); + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "one"), origin: null, Ct); + + // Act + await run.DisposeAsync(); + + // Assert + List snapshot; + lock (spans) + { + snapshot = [.. spans]; + } + + Activity span = Assert.Single(snapshot, item => string.Equals( + item.GetTagItem("gen_ai.conversation.id") as string, session.ConversationId, StringComparison.Ordinal)); + Assert.Equal("interrupted", span.GetTagItem("agentcore.turn.outcome")); + + lock (durations) + { + Assert.NotEmpty(durations); + } + } + + // Owner ruling: a turn that is refused or dropped for any reason leaves a log line and an audit event. + [Fact] + public async Task ARunDisposedUnread_LeavesATurnRefusedThatSaysItWasDropped() + { + // Arrange + (ConversationSession session, _, InMemoryAuditSink sink) = Create(TurnScriptChatClient.Text("reply")); + TurnRun run = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "one"), origin: null, Ct); + + // Act + await run.DisposeAsync(); + + // Assert + AuditEvent refused = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnRefused); + Assert.Equal((0, "dropped"), (refused.TurnIndex, refused.Payload[AuditPayloadKeys.RefusedReason])); + } + + [Fact] + public async Task ATurnStartedWhileAnotherRunsPastTheWaitLimit_IsRefusedAsBusy() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + InMemoryAuditSink sink = new(); + ConversationSession session = Create(TurnScriptChatClient.Text("reply"), new RecordingConversationStore(), sink, time); + await using TurnRun running = await session.StartTurnAsync(new ChatMessage(ChatRole.User, "one"), origin: null, Ct); + + // Act + Task starting = session.StartTurnAsync(new ChatMessage(ChatRole.User, "two"), origin: null, Ct); + _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.WaitLimit, 1), starting); + time.Advance(ConversationBusyMark.WaitLimit); + + // Assert + _ = await Assert.ThrowsAsync(() => starting); + AuditEvent refused = Assert.Single(sink.EventsOf(session.ConversationId), item => item.Kind == AuditEventKind.TurnRefused); + Assert.Equal(((int?)null, "busy"), (refused.TurnIndex, refused.Payload[AuditPayloadKeys.RefusedReason])); + } + + /// Subscribes to the one activity source of this library, and no other. + private static ActivityListener ListenToAgentCoreTelemetry(List spans) + { + ActivityListener listener = new() + { + ShouldListenTo = source => + string.Equals(source.Name, AgentCoreTelemetry.ActivitySourceName, StringComparison.Ordinal), + Sample = (ref _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + lock (spans) + { + spans.Add(activity); + } + }, + }; + + ActivitySource.AddActivityListener(listener); + return listener; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs new file mode 100644 index 00000000..30adea0e --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationSessionWriterOrderTests.cs @@ -0,0 +1,159 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.State; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// The writers, in their fixed order: const, counter, tool, then extractor. + public sealed class ConversationSessionWriterOrderTests + { + // The writers, in order. + [Fact] + public void TheConstWriter_RunsOnceWhenTheSessionStarts() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + // A constant never changes, so it is written before the first turn and never again. + Assert.Equal("sole", session.State.Read("brand")!.GetValue()); + Assert.False(session.State.IsUnfilled("brand")); + } + + [Fact] + public async Task TheCounter_RunsBeforeTheTransitionAndReadsTheStageTheTurnSpokeIn() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(SaidGoodbye); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + TurnResult turn = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + + // The turn spoke in greeting and the machine then moved to close. The counter rule reads + // stage === greeting, so a counter that ran after the transition would report zero. + Assert.Equal("greeting", turn.StageBefore); + Assert.Equal("close", turn.StageAfter); + Assert.Equal(1, session.State.Read("greetingTurns")!.GetValue()); + } + + [Fact] + public async Task TheToolWriter_RunsBeforeTheCounterThatReadsItsSlot() + { + using ToolCallingChatClient reply = new("your order is on the way."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(ToolYaml, reply, fill, new StubToolBuilder(/*lang=json,strict*/ """{ "status": "shipped" }""").Create).Create(); + + TurnResult turn = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + Assert.Equal(["lookup_order"], reply.Called); + Assert.Equal("shipped", session.State.Read("orderStatus")!.GetValue()); + + // The counter rule reads orderStatus. It is one only because the tool writer went first. + Assert.Equal(1, session.State.Read("shippedTurns")!.GetValue()); + Assert.Equal("greeting", turn.StageAfter); + } + + [Fact] + public async Task TheToolWriter_ReadsAResultTheToolAnsweredAsText() + { + using ToolCallingChatClient reply = new("your order is on the way."); + using SequencedChatClient fill = new(StayingNull); + StubToolBuilder tools = new(/*lang=json,strict*/ """{ "status": "shipped" }""", asText: true); + ConversationSession session = Build(ToolYaml, reply, fill, tools.Create).Create(); + + _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + // A tool result has no declared shape, so a document answered as one string still resolves. + Assert.Equal("shipped", session.State.Read("orderStatus")!.GetValue()); + } + + [Fact] + public async Task TheExtractor_RunsBeforeTheTransitionThatReadsItsSlot() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(SaidGoodbye); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + TurnResult turn = await session.RunTurnAsync("goodbye", TestContext.Current.CancellationToken); + + // The guard reads callerSaidGoodbye, and only the extractor writes it. + Assert.True(session.State.Read("callerSaidGoodbye")!.GetValue()); + Assert.Equal("close", turn.StageAfter); + Assert.True(turn.IsTerminal); + Assert.True(session.IsComplete); + } + + [Fact] + public async Task TheExtractor_ReadsTheFinishedTurn() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new(StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // extractor.when: after_reply. The request holds what the caller said and what the agent + // answered, and the caller's message carries no reminder. + List request = fill.Requests[0]; + Assert.Contains(request, message => Contains(message, "hi")); + Assert.Contains(request, message => Contains(message, "hello there.")); + Assert.DoesNotContain(request, message => Contains(message, UnfilledSlotReminder.OpenTag)); + } + + [Fact] + public async Task TheExtractor_ReadsTheAgentsPreviousMessageBeforeTheFinishedTurn() + { + using SequencedChatClient reply = new("is it the CT900 or the CT900ENT?", "got it."); + using SequencedChatClient fill = new(StayingNull, StayingNull); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + _ = await session.RunTurnAsync("my belt slips", TestContext.Current.CancellationToken); + _ = await session.RunTurnAsync("the ENT one", TestContext.Current.CancellationToken); + + // Turn 1 has no earlier agent message, so the finished turn stands alone. + List first = [.. fill.Requests[0].Where(message => message.Role != ChatRole.System)]; + Assert.Equal([ChatRole.User, ChatRole.Assistant], first.Select(message => message.Role)); + + // Turn 2: the caller is answering a question. Measured 2026-09-02: without the question in + // view the extractor reads "the ENT one" and fills nothing; with it, it fills the machine. + List second = [.. fill.Requests[1].Where(message => message.Role != ChatRole.System)]; + Assert.Equal([ChatRole.Assistant, ChatRole.User, ChatRole.Assistant], second.Select(message => message.Role)); + Assert.Equal("is it the CT900 or the CT900ENT?", second[0].Text); + Assert.Equal("the ENT one", second[1].Text); + Assert.Equal("got it.", second[2].Text); + } + + [Fact] + public async Task AFailedExtraction_DoesNotDropTheTurn() + { + using SequencedChatClient reply = new("hello there."); + using SequencedChatClient fill = new("I am sorry, I cannot do that."); + ConversationSession session = Build(PolicyYaml, reply, fill).Create(); + + TurnResult turn = await session.RunTurnAsync("hi", TestContext.Current.CancellationToken); + + // Section 8.7: the extractor never drops a conversation. The turn ends, and it carries the reason. + Assert.NotNull(turn.ExtractionFailure); + Assert.Equal("hello there.", turn.ReplyText); + Assert.Equal("greeting", turn.StageAfter); + Assert.True(session.State.IsUnfilled("callerSaidGoodbye")); + } + + [Fact] + public async Task ADocumentWithNoExtractor_RunsATurnAndReportsNoFailure() + { + using SequencedChatClient reply = new("first reply."); + ConversationSession session = Build(TwoStagesYaml, reply, null).Create(); + + TurnResult turn = await session.RunTurnAsync("one", TestContext.Current.CancellationToken); + + Assert.Null(turn.ExtractionFailure); + Assert.Equal(1, reply.Calls); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs new file mode 100644 index 00000000..30cd4fcb --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentCutTests.cs @@ -0,0 +1,121 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The Cut rule over the real : a cut, a host cancel and an abandoned stream + /// each end in the turn's one append (docs/handoff/2026-09-22-maf-native-engine-design.md, section 3). + /// + public sealed class ConversationTurnAgentCutTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Probe W03: a cut mid-text keeps the user and the shown text, in one append. + [Fact] + public async Task CutMidText_KeepsTheUserAndTheShownText_InOneAppend() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text("Hel", "lo ", "there")); + HarnessTurn turn = h.Begin("hi", turnIndex: 0); + + // Act + _ = await h.DrainAsync(turn, update => + { + if (update.Text.Length > 0) + { + _ = turn.Slot.TryCut(new TurnCut("He", TimeSpan.FromMilliseconds(300))); + } + }); + + // Assert + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["hi", "He"], Texts(h.Store.Rows.Select(row => row.Content))); + Assert.Empty(h.Store.Rewrites); + } + + // Probe W04 (G1): a cut before any output keeps the user message only, and the turn before stays untouched. + [Fact] + public async Task CutBeforeOutput_KeepsTheUserOnly_AndLeavesThePreviousTurnUntouched() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["first"], ["second"]); + model.GateBeforeCall = 1; + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(model); + _ = await h.RunAsync("one", turnIndex: 0); + HarnessTurn second = h.Begin("two", turnIndex: 1); + + // Act + Task> drain = h.DrainAsync(second); + await model.Gated.Task.WaitAsync(Ct); + _ = second.Slot.TryCut(new TurnCut(string.Empty, null)); + model.Release.TrySetResult(); + _ = await drain; + + // Assert + Assert.Equal(2, h.Store.Appends); + Assert.Empty(h.Store.Rewrites); + Assert.Equal(["one", "first", "two"], Texts(h.History.Read(h.Session))); + } + + // Probe W05: a host cancel keeps the user and everything yielded, in one append the hook never fed. + [Fact] + public async Task HostCancel_KeepsTheUserAndEverythingYielded_InOneAppend() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text("Hel", "lo ", "there")); + using CancellationTokenSource host = new(); + HarnessTurn turn = h.Begin("hi", turnIndex: 0, host: host); + + // Act + _ = await Assert.ThrowsAnyAsync(() => h.DrainAsync(turn, update => + { + if (update.Text.Length > 0) + { + host.Cancel(); + } + })); + await h.History.DrainAsync(h.Session); + + // Assert + Assert.Empty(turn.Completer.StagedAtComplete); + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["hi", "Hel"], Texts(h.Store.Rows.Select(row => row.Content))); + } + + // Probe W06: a reader that abandons the stream still gets the turn committed, from the layer's finally. + [Fact] + public async Task ReaderAbandonsTheStream_TheLayerCommitsTheUserAndTheYieldedText_InOneAppend() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text("Hel", "lo ", "there")); + HarnessTurn turn = h.Begin("hi", turnIndex: 0); + + // Act + await foreach (AgentResponseUpdate update in h.Agent.RunStreamingAsync( + [turn.Invocation.User!], h.Session, turn.Invocation.RunOptions(), turn.Slot.Token)) + { + if (update.Text.Length > 0) + { + break; + } + } + + await h.History.DrainAsync(h.Session); + + // Assert + Assert.DoesNotContain("staged", h.Log); + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["hi", "Hel"], Texts(h.Store.Rows.Select(row => row.Content))); + } + + private static List Texts(IEnumerable messages) + { + return [.. messages.Where(message => message.Text.Length > 0).Select(message => message.Text)]; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs new file mode 100644 index 00000000..cd917faa --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentHarness.cs @@ -0,0 +1,110 @@ +using AgentCore.Application.Evaluation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Evaluation.Fakes; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The design probes' harness over the real types: one with a tool, the real + /// fallback and moderation layers, and outermost, all on one real provider. + /// + internal sealed class ConversationTurnAgentHarness + { + internal const string ConversationId = "c"; + + private ConversationTurnAgentHarness( + AgentCoreChatHistoryProvider history, RecordingConversationStore store, AIAgent agent, AgentSession session) + { + History = history; + Store = store; + Agent = agent; + Session = session; + } + + public AgentCoreChatHistoryProvider History { get; } + + public RecordingConversationStore Store { get; } + + public AIAgent Agent { get; } + + public AgentSession Session { get; } + + /// Gets the order the completer and the store saw: staged, complete, append. + public List Log { get; } = []; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public static async Task CreateAsync( + IChatClient model, ScriptedModerationEvaluator? moderation = null) + { + RecordingConversationStore store = new(); + _ = await store.CreateAsync(ConversationId, Ct); + AgentCoreChatHistoryProvider history = new(store); + + AIAgent layers = new FallbackAgent(ToolAgent(model, history, "only"), "fallback"); + if (moderation is not null) + { + layers = new ModerationAgent(layers, new PromptModerator(moderation), "refused", TimeSpan.FromSeconds(2)); + } + + ConversationTurnAgent top = new(layers, history); + AgentSession session = await top.CreateSessionAsync(Ct); + _ = history.BeginConversation(session, ConversationId, []); + + ConversationTurnAgentHarness harness = new(history, store, top, session); + store.Log = harness.Log; + return harness; + } + + /// Builds one tool-carrying chat agent on the provider. + public static ChatClientAgent ToolAgent(IChatClient model, ChatHistoryProvider history, string name) + { + return new ChatClientAgent( + new FunctionInvokingChatClient(model), + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions { Tools = [AIFunctionFactory.Create(() => "42", "lookup")] }, + ChatHistoryProvider = history, + }); + } + + /// Opens one turn the way the runner files it. + public HarnessTurn Begin(string text, int turnIndex, ConversationTurnOrigin? origin = null, CancellationTokenSource? host = null) + { + History.BeginTurn(Session, turnIndex); + return HarnessTurn.Open(History, Session, text, turnIndex, carriesHistory: true, Log, origin, host); + } + + /// Streams one opened turn to its end. + /// The opened turn. + /// Sees each update as the reader gets it, or . + public async Task> DrainAsync(HarnessTurn turn, Action? onUpdate = null) + { + List updates = []; + await foreach (AgentResponseUpdate update in Agent.RunStreamingAsync( + [turn.Invocation.User!], Session, turn.Invocation.RunOptions(), turn.Slot.Token)) + { + updates.Add(update); + onUpdate?.Invoke(update); + } + + await History.DrainAsync(Session); + return updates; + } + + /// Runs one turn to its end, streaming. + public async Task RunAsync(string text, int turnIndex, ConversationTurnOrigin? origin = null) + { + HarnessTurn turn = Begin(text, turnIndex, origin); + turn.Updates = await DrainAsync(turn); + return turn; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs new file mode 100644 index 00000000..9d468d52 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentSealTests.cs @@ -0,0 +1,121 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// When seals a turn and when it does not: a nested run never seals, a fault + /// above every fallback is sealed as a fault, and an abandoned run still closes the stream beneath it. + /// + public sealed class ConversationTurnAgentSealTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // The layer's remarks: a nested run shares its parent's turn but never seals it. + [Fact] + public async Task ANestedRun_PassesStraightThrough_AndLeavesTheParentTurnUnsealed() + { + // Arrange + Opened o = await OpenAsync(new ScriptedRunAgent(["inner words"])); + TurnInvocation nested = o.Turn.Invocation with { Nested = true }; + + // Act + List updates = []; + await foreach (AgentResponseUpdate update in o.Agent.RunStreamingAsync([nested.User!], o.Session, nested.RunOptions(), Ct)) + { + updates.Add(update); + } + + await o.History.DrainAsync(o.Session); + + // Assert + Assert.DoesNotContain("complete", o.Log); + Assert.Equal(0, o.Store.Appends); + Assert.DoesNotContain(updates, update => update.Contents.OfType().Any()); + Assert.True(o.Turn.Slot.TrySeal(out _)); + } + + // Section 8.7, row six: a fault above every fallback still ends in a turn sealed with that fault, and the + // reader's stream ends without it. + [Fact] + public async Task AFaultAboveEveryFallback_IsSealedAsAFault_AndTheReaderSeesNoException() + { + // Arrange + Opened o = await OpenAsync(new ScriptedRunAgent(["Hel"], new InvalidOperationException("boom"))); + + // Act + Exception? thrown = await Record.ExceptionAsync(async () => + { + await foreach (AgentResponseUpdate _ in o.Agent.RunStreamingAsync( + [o.Turn.Invocation.User!], o.Session, o.Turn.Invocation.RunOptions(), o.Turn.Slot.Token)) + { + } + }); + await o.History.DrainAsync(o.Session); + + // Assert + Assert.Null(thrown); + Assert.Equal(1, o.Store.Appends); + Assert.Equal(["hi", "Hel", "fallback"], Texts(o.Store.Rows.Select(row => row.Content))); + } + + // Design row W06: a reader that stops reading disposes the layer's stream, and the layer disposes the run + // beneath it rather than leaving it suspended. + [Fact] + public async Task AReaderThatStopsReading_ClosesTheRunBeneathTheLayer() + { + // Arrange + ScriptedRunAgent inner = new(["Hel", "lo"]); + Opened o = await OpenAsync(inner); + + // Act + await foreach (AgentResponseUpdate update in o.Agent.RunStreamingAsync( + [o.Turn.Invocation.User!], o.Session, o.Turn.Invocation.RunOptions(), o.Turn.Slot.Token)) + { + if (update.Text.Length > 0) + { + break; + } + } + + await o.History.DrainAsync(o.Session); + + // Assert + Assert.True(inner.StreamClosed); + Assert.Equal(1, o.Store.Appends); + } + + private static async Task OpenAsync(ScriptedRunAgent inner) + { + RecordingConversationStore store = new(); + _ = await store.CreateAsync(ConversationTurnAgentHarness.ConversationId, Ct); + AgentCoreChatHistoryProvider history = new(store); + ConversationTurnAgent agent = new(inner, history); + AgentSession session = await agent.CreateSessionAsync(Ct); + _ = history.BeginConversation(session, ConversationTurnAgentHarness.ConversationId, []); + history.BeginTurn(session, 0); + List log = []; + HarnessTurn turn = HarnessTurn.Open(history, session, "hi", turnIndex: 0, carriesHistory: true, log); + return new Opened(agent, session, turn, store, history, log); + } + + private sealed record Opened( + ConversationTurnAgent Agent, + AgentSession Session, + HarnessTurn Turn, + RecordingConversationStore Store, + AgentCoreChatHistoryProvider History, + List Log); + + private static List Texts(IEnumerable messages) + { + return [.. messages.Where(message => message.Text.Length > 0).Select(message => message.Text)]; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs new file mode 100644 index 00000000..b9574290 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationTurnAgentTests.cs @@ -0,0 +1,198 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Evaluation.Fakes; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// as the one trigger of a turn's durable write: the design's revision 2 + /// probes, ported over the real layer, provider, fallback and moderation layers + /// (docs/handoff/2026-09-22-maf-native-engine-design.md, section 5a). + /// + public sealed class ConversationTurnAgentTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Probe W01: the hook stages, the stage machine runs, then one write holds the words and the fresh state. + [Fact] + public async Task CompletedTurn_HookStagesThenCompleteThenOneAppend_WithTheStateAfterTheAdvance() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync( + TurnScriptChatClient.ToolThenText("lookup", "it is 42")); + + // Act + HarnessTurn turn = await h.RunAsync("price?", turnIndex: 0); + + // Assert + Assert.Equal(["staged", "complete", "append"], h.Log); + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["user:text", "assistant:call", "tool:result", "assistant:text"], Kinds(h.Store.Rows.Select(row => row.Content))); + Assert.Equal(Kinds(turn.Completer.StagedAtComplete), Kinds(h.Store.Rows.Skip(1).Select(row => row.Content))); + Assert.Equal("stage-after:it is 42", Assert.Single(h.Store.States)!.Stage); + Assert.Empty(h.History.Staged(h.Session)); + } + + // Probe W02: a buffered caller gets the reply, and the turn is the same single write. + [Fact] + public async Task BufferedCaller_GetsTheReply_AndTheTurnIsOneAppendFromTheHook() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text("hello")); + HarnessTurn turn = h.Begin("hi", turnIndex: 0); + + // Act + AgentResponse response = await h.Agent.RunAsync( + [turn.Invocation.User!], h.Session, turn.Invocation.RunOptions(), turn.Slot.Token); + await h.History.DrainAsync(h.Session); + + // Assert + Assert.Equal("hello", response.Text); + Assert.DoesNotContain(response.Messages.SelectMany(message => message.Contents), content => content is TurnCommittedContent); + Assert.Equal(1, h.Store.Appends); + Assert.NotEmpty(turn.Completer.StagedAtComplete); + Assert.Equal(["hi", "hello"], Texts(h.Store.Rows.Select(row => row.Content))); + } + + // Probe W07: a tool fault keeps the user, the finished pair and the fallback, in one write the hook never fed. + [Fact] + public async Task ToolFault_KeepsTheUserTheFinishedPairAndTheFallback_InOneAppend() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.ToolThenFault("lookup")); + + // Act + HarnessTurn turn = await h.RunAsync("price?", turnIndex: 0); + + // Assert + Assert.Empty(turn.Completer.StagedAtComplete); + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["user:text", "assistant:call", "tool:result", "assistant:text"], Kinds(h.Store.Rows.Select(row => row.Content))); + Assert.Equal("fallback", h.Store.Rows[^1].Content.Text); + } + + // Probe W08: a moderation refusal keeps the user and the refusal in one write, and the model is never called. + [Fact] + public async Task ModerationRefusal_KeepsTheUserAndTheRefusal_InOneAppend() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Text("never"); + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(model, ScriptedModerationEvaluator.Flagging("hate")); + + // Act + _ = await h.RunAsync("...", turnIndex: 0); + + // Assert + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["...", "refused"], Texts(h.Store.Rows.Select(row => row.Content))); + Assert.Equal(0, model.Calls); + } + + // Probe W09: the hook stages the empty reply and never sees the fallback; the seal writes the fallback. + [Fact] + public async Task EmptyReply_HookStagesNoFallback_TheSealWritesIt_InOneAppend() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text(" ")); + + // Act + HarnessTurn turn = await h.RunAsync("hi", turnIndex: 0); + + // Assert + Assert.NotEmpty(turn.Completer.StagedAtComplete); + Assert.DoesNotContain("fallback", turn.Completer.StagedAtComplete.Select(message => message.Text)); + Assert.Equal(1, h.Store.Appends); + Assert.Equal(["hi", "fallback"], Texts(h.Store.Rows.Select(row => row.Content))); + } + + // Probe G2: a graph's output reaches the conversation through the layer; the node sessions write nothing. + [Fact] + public async Task GraphRow_TheOutputReachesTheConversationThroughTheLayer_TheNodesWriteNothing() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text("unused")); + ChatClientAgent first = ConversationTurnAgentHarness.ToolAgent(TurnScriptChatClient.Text("one"), h.History, "first"); + ChatClientAgent second = ConversationTurnAgentHarness.ToolAgent(TurnScriptChatClient.Text("two"), h.History, "second"); + AIAgent graph = AgentWorkflowBuilder.BuildSequential("g", [first, second]).AsAIAgent(name: "g"); + ConversationTurnAgent layered = new(graph, h.History); + AgentSession run = await graph.CreateSessionAsync(Ct); + h.History.BeginTurn(h.Session, 0); + HarnessTurn turn = HarnessTurn.Open(h.History, h.Session, "hi", turnIndex: 0, carriesHistory: false, h.Log); + + // Act + await foreach (AgentResponseUpdate _ in layered.RunStreamingAsync( + [turn.Invocation.User!], run, turn.Invocation.RunOptions(), turn.Slot.Token)) + { + } + + await h.History.DrainAsync(h.Session); + + // Assert + Assert.Equal(1, h.Store.Appends); + Assert.All(h.Store.Rows, row => Assert.Equal(ConversationTurnAgentHarness.ConversationId, row.ConversationId)); + Assert.Equal(["hi", "two"], Texts(h.Store.Rows.Select(row => row.Content))); + } + + // Probe G3: the committed ids reach a streaming caller on the turn's last update. + [Fact] + public async Task StreamingCaller_GetsTheCommittedIds_OnTheLastUpdate() + { + // Arrange + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(TurnScriptChatClient.Text("hello")); + + // Act + HarnessTurn turn = await h.RunAsync("hi", turnIndex: 0); + + // Assert + TurnCommittedContent committed = Assert.Single(turn.Updates[^1].Contents.OfType()); + Assert.Equal(h.Store.Rows[0].MessageId, committed.UserMessageId); + Assert.Equal(h.Store.Rows[^1].MessageId, committed.ReplyMessageId); + } + + // Probe G5: an edit is a provider command the layer triggers: the truncate, then one append, and the model + // no longer reads the withdrawn words. + [Fact] + public async Task Edit_TheLayerTruncatesThenAppendsOnce_AndTheModelNoLongerReadsTheWithdrawnWords() + { + // Arrange + TurnScriptChatClient model = TurnScriptChatClient.Sequence(["a1"], ["a2"], ["a3"]); + ConversationTurnAgentHarness h = await ConversationTurnAgentHarness.CreateAsync(model); + HarnessTurn first = await h.RunAsync("q1", turnIndex: 0); + _ = await h.RunAsync("q2", turnIndex: 1); + ConversationTurnOrigin origin = new(MessageId: null, ParentMessageId: first.Completer.Ids!.Value.ReplyMessageId) { NamesParent = true }; + + // Act + HarnessTurn edited = await h.RunAsync("q2 edited", turnIndex: 2, origin); + + // Assert + Assert.Equal(new WithdrawnTurns(1, 1), edited.Completer.Withdrawn); + Assert.Equal(3, h.Store.Appends); + Assert.Equal(["q1", "a1", "q2 edited", "a3"], Texts(h.History.Read(h.Session))); + Assert.DoesNotContain(model.Requests[2], message => message.Text is "q2" or "a2"); + } + + private static List Texts(IEnumerable messages) + { + return [.. messages.Where(message => message.Text.Length > 0).Select(message => message.Text)]; + } + + private static List Kinds(IEnumerable messages) + { + return [.. messages.Select(message => $"{message.Role}:{Kind(message)}")]; + } + + private static string Kind(ChatMessage message) + { + return message.Contents.Any(content => content is FunctionCallContent) ? "call" + : message.Contents.Any(content => content is FunctionResultContent) ? "result" + : "text"; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs new file mode 100644 index 00000000..d1937cfd --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceMarkerVisibilityTests.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Reflection; +using AgentCore.Application.Runtime; +using Microsoft.Agents.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The workspace marker sits outside the folder, as root/conversationId.agentcore-workspace, so a + /// model working inside the folder — listing it, indexing it for memory, or cloning a repository into it — + /// never sees anything AgentCore itself put there. + /// +#pragma warning disable MAAI001 // File-store and memory-provider types are evaluation-only in Microsoft.Agents.AI 1.21.0. + public sealed class ConversationWorkspaceMarkerVisibilityTests : IDisposable + { + private readonly string _root = Directory.CreateTempSubdirectory("agentcore-ws-marker-vis-").FullName; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task TheMarkerDoesNotShowUpInTheFileAccessListing() + { + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "c1"); + FileSystemAgentFileStore store = new(workspace.Path); + + IReadOnlyList entries = await store.ListChildrenAsync("", TestContext.Current.CancellationToken); + + Assert.DoesNotContain(entries, entry => entry.Name == ConversationWorkspace.MarkerFileName); + } + + [Fact] + public async Task TheMarkerDoesNotShowUpInTheMemoryIndex() + { + _ = ConversationWorkspace.Create(_root, "c1"); + FileSystemAgentFileStore store = new(_root); + await File.WriteAllTextAsync(Path.Combine(_root, "c1", "note.md"), "x", TestContext.Current.CancellationToken); + FileMemoryProvider memory = new(store, _ => new FileMemoryState { WorkingFolder = "c1" }); + + MethodInfo rebuild = typeof(FileMemoryProvider) + .GetMethod("RebuildMemoryIndexAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + await (Task)rebuild.Invoke(memory, [new FileMemoryState { WorkingFolder = "c1" }, TestContext.Current.CancellationToken])!; + + string index = await File.ReadAllTextAsync(Path.Combine(_root, "c1", "memories.md"), TestContext.Current.CancellationToken); + Assert.DoesNotContain(ConversationWorkspace.MarkerFileName, index, StringComparison.Ordinal); + } + + [Fact] + public void GitCloneIntoTheWorkspaceSucceeds() + { + string src = Directory.CreateDirectory(Path.Combine(_root, "src-repo")).FullName; + Run(src, "git", "init", "-q"); + File.WriteAllText(Path.Combine(src, "a.txt"), "a"); + Run(src, "git", "-c", "user.email=a@b", "-c", "user.name=a", "add", "."); + Run(src, "git", "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-qm", "x"); + + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "c1"); + + Assert.Equal(0, Run(workspace.Path, "git", "clone", "-q", src, ".")); + } + + private static int Run(string cwd, string file, params string[] args) + { + ProcessStartInfo psi = new(file) { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true }; + foreach (string a in args) + { + psi.ArgumentList.Add(a); + } + + using Process process = Process.Start(psi)!; + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + TestContext.Current.SendDiagnosticMessage($"{file} {string.Join(' ', args)} -> {process.ExitCode} {error}"); + return process.ExitCode; + } + } +#pragma warning restore MAAI001 +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs index 98118741..da6f3e90 100644 --- a/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/ConversationWorkspaceTests.cs @@ -1,10 +1,13 @@ using AgentCore.Application.Runtime; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; using Xunit; namespace AgentCore.Application.Tests.Runtime { /// - /// in isolation: the folder it creates and the folder it deletes. + /// in isolation: the folder it creates, the folder it stamps, and the + /// folder it deletes. /// public sealed class ConversationWorkspaceTests : IDisposable { @@ -39,6 +42,17 @@ public void Delete_RemovesTheFolder_AndIsSafeToCallTwice() Assert.Null(exception); } + [Fact] + public void Delete_AlsoRemovesTheSiblingMarker() + { + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "conversation-1"); + Assert.True(File.Exists(workspace.MarkerPath)); + + workspace.Delete(logger: null); + + Assert.False(File.Exists(workspace.MarkerPath)); + } + [Theory] [InlineData("../escape")] [InlineData("a/b")] @@ -78,6 +92,60 @@ public void Create_RefusesOrExactlyHonoursAConversationIdWithATrailingSpaceOrPer Assert.Equal(conversationId, Path.GetFileName(workspace.Path)); } + [Fact] + public void Touch_SetsTheFoldersLastWriteTimeToTheClocksNow() + { + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "conversation-1"); + FakeTimeProvider clock = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + clock.Advance(TimeSpan.FromMinutes(5)); + + workspace.Touch(clock, logger: null); + + Assert.Equal(clock.GetUtcNow().UtcDateTime, Directory.GetLastWriteTimeUtc(workspace.Path)); + } + + [Fact] + public void Touch_OnAFolderAlreadyDeleted_DoesNotThrow() + { + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "conversation-1"); + Directory.Delete(workspace.Path, recursive: true); + + Exception? exception = Record.Exception(() => workspace.Touch(TimeProvider.System, logger: null)); + + Assert.Null(exception); + } + + [Fact] + public void Touch_OnAFolderAlreadyDeleted_LogsNothing() + { + // A stamp can race a close: the turn that owns it is ending just as the idle poll (or another + // turn's touch) fires. A missing folder there is the ordinary outcome of that race, not a fault — + // Delete already treats it that way, and Touch must too, or every such race would warn for nothing. + using RecordingLoggerFactory loggers = new(); + ILogger logger = loggers.CreateLogger("x"); + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "conversation-1"); + Directory.Delete(workspace.Path, recursive: true); + + workspace.Touch(TimeProvider.System, logger); + + Assert.Empty(loggers.Lines); + } + + [Fact] + public void Create_WhenTheMarkerPathIsADirectory_StillMakesTheFolderAndLogsAWarning() + { + // The marker write must never be load-bearing for the conversation getting a working folder: a + // collision at the marker's path only means the boot sweep will never consider this folder. + Directory.CreateDirectory(ConversationWorkspace.MarkerPathFor(Path.Combine(_root, "conversation-1"))); + using RecordingLoggerFactory loggers = new(); + ILogger logger = loggers.CreateLogger("x"); + + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "conversation-1", logger: logger); + + Assert.True(Directory.Exists(workspace.Path)); + Assert.Contains(loggers.Lines, line => line.Level == LogLevel.Warning); + } + [Fact] public void Create_TwiceWithTheSameConversationId_ReusesTheFolderAndKeepsWhatWasWrittenIntoIt() { diff --git a/tests/AgentCore.Application.Tests/Runtime/DownModelChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/DownModelChatClient.cs new file mode 100644 index 00000000..eae6be86 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/DownModelChatClient.cs @@ -0,0 +1,40 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// A model endpoint that answers every request with a transport fault, before any word or tool call. + internal sealed class DownModelChatClient : IChatClient + { + /// The message every fault carries. + public const string Message = "503 from the model endpoint"; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + throw new HttpRequestException(Message); +#pragma warning disable CS0162 // The iterator needs a yield to be one; the throw above always ends it first. + yield break; +#pragma warning restore CS0162 + } + + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + throw new HttpRequestException(Message); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs b/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs new file mode 100644 index 00000000..0c1e70c6 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/FailsOnceThenAnswersModel.cs @@ -0,0 +1,41 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// A model that throws once, like a down endpoint recovering, then answers. + internal sealed class FailsOnceThenAnswersModel : IChatClient + { + private int _calls; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + if (Interlocked.Increment(ref _calls) == 1) + { + throw new HttpRequestException("503 from the model endpoint"); + } + + string id = Guid.NewGuid().ToString("N"); + yield return new ChatResponseUpdate(ChatRole.Assistant, "adder ok") { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate u in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(u); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs b/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs new file mode 100644 index 00000000..a60d4265 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/GraphConversationSessions.cs @@ -0,0 +1,33 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Compiles the main entry of a graph document into a conversation session. + internal static class GraphConversationSessions + { + /// Builds a session whose agents first and second talk to the given models. + /// A document whose main entry is a graph over agents named first and second. + /// The model of the agent first. + /// The model of the agent second. + /// Builds each tools: entry, or returns to leave it unbuilt. + /// The test's token, for the tool registry. + public static ConversationSession Create( + string yaml, IChatClient first, IChatClient second, Func tools, CancellationToken cancellationToken) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + RoutingChatClientFactory clients = new(first); + _ = clients.Route("first", first); + _ = clients.Route("second", second); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(clients) { Tools = TestToolRegistry.From(document, tools, cancellationToken) })["main"]; + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/HangUntilCancelledChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/HangUntilCancelledChatClient.cs new file mode 100644 index 00000000..673a0d23 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/HangUntilCancelledChatClient.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// A model that stays in its call until the token it was handed cancels it. + internal sealed class HangUntilCancelledChatClient : IChatClient + { + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _cancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes once a request reaches the model. + public Task Started => _started.Task; + + /// Completes once a request that reached the model is cancelled. + public Task Cancelled => _cancelled.Task; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = _started.TrySetResult(); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _ = _cancelled.TrySetResult(); + throw; + } + + yield break; + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs index e871ec40..3391a43f 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationFilesProviderTests.cs @@ -13,8 +13,8 @@ namespace AgentCore.Application.Tests.Runtime.Harness /// /// as a unit: it serves the framework's file tools rooted at the - /// workspace of the turn filed for the invoking session, and refuses to serve any when no turn - /// is filed. + /// workspace of the turn filed for the invoking session, and serves an empty context when no turn + /// is filed (a delegated agent's run, design §6.3#6, inverse of probe P11c). /// public sealed class ConversationFilesProviderTests : IDisposable { @@ -46,16 +46,16 @@ public async Task InvokingAsync_WithASessionFiledTurn_ServesFileTools() Assert.NotEmpty(context.Tools); } + // Inverse of P11c (design §6.3#6): a run with no filed turn gets an empty context, not a throw. [Fact] - public async Task InvokingAsync_WithoutATurn_ThrowsInvalidOperationException() + public async Task InvokingAsync_WithoutATurn_ServesAnEmptyContext() { using ConversationFilesProvider provider = new(_root, Options()); - InvalidOperationException exception = await Assert.ThrowsAsync( - () => provider.InvokingAsync( - Invoking(new StubSession()), TestContext.Current.CancellationToken).AsTask()); + AIContext context = await provider.InvokingAsync( + Invoking(new StubSession()), TestContext.Current.CancellationToken); - Assert.Contains("files: tool", exception.Message, StringComparison.Ordinal); + Assert.Null(context.Tools); } private static FileAccessProviderOptions Options() diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs index 8ff5c9a7..9a46db16 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionBackgroundTests.cs @@ -2,6 +2,8 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; @@ -68,9 +70,10 @@ public async Task CloseAsync_WithAChildStillRunning_CancelsIt() ["description"] = "d", }); BlockingChatClient child = new(); - InMemoryConversationSessions sessions = new(BuildFactory(LingeringChildYaml, parent, child), TimeSpan.FromMinutes(30), TimeProvider.System); + InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(LingeringChildYaml, parent, child)), TimeSpan.FromMinutes(30), TimeProvider.System); - ConversationSession session = await sessions.OpenAsync("conversation-1", token); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, token); _ = await session.RunTurnAsync("go", token); // The turn is over and the conversation is not, and the child is still in its model call: the @@ -78,7 +81,7 @@ public async Task CloseAsync_WithAChildStillRunning_CancelsIt() await child.Started.WaitAsync(TimeSpan.FromSeconds(10), token); Assert.False(child.Cancelled.IsCompleted); - await sessions.CloseAsync("conversation-1", token); + await sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", token); await child.Cancelled.WaitAsync(TimeSpan.FromSeconds(10), token); } @@ -106,14 +109,66 @@ public async Task ATurnThatReachesATerminalStage_CancelsAChildStillRunningWithou await child.Cancelled.WaitAsync(TimeSpan.FromSeconds(10), token); } - private static ConversationSessionFactory BuildFactory(string yaml, IChatClient parent, IChatClient child) + [Fact] + public async Task ATurnOnTheSameHost_LeavesARunningChildAlone() + { + CancellationToken token = TestContext.Current.CancellationToken; + ToolCallingChatClient parent = new( + "started", + new Dictionary(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }); + BlockingChatClient child = new(); + ConversationSession session = BuildFactory(LingeringChildYaml, parent, child, new InMemoryConversationStore()).Create("conversation-1"); + + _ = await session.RunTurnAsync("go", token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), token); + _ = await session.RunTurnAsync("status?", token); + + Assert.False(child.Cancelled.IsCompleted); + + await session.DisposeAsync(); + await child.Cancelled.WaitAsync(TimeSpan.FromSeconds(10), token); + } + + [Fact] + public async Task ASessionThatCatchesUpOnAnotherHostsTurn_CancelsItsChildAtTheCatchUp() + { + CancellationToken token = TestContext.Current.CancellationToken; + InMemoryConversationStore store = new(); + ToolCallingChatClient parent = new( + "started", + new Dictionary(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }); + BlockingChatClient child = new(); + await using ConversationSession a = BuildFactory(LingeringChildYaml, parent, child, store).Create("conversation-1"); + await using ConversationSession b = BuildFactory(LingeringChildYaml, new ScriptedChatClient("fine"), new BlockingChatClient(), store) + .Create("conversation-1"); + + _ = await a.RunTurnAsync("go", token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), token); + _ = await b.RunTurnAsync("hello from the other host", token); + TurnResult caughtUp = await a.RunTurnAsync("status?", token); + + Assert.Equal(2, caughtUp.TurnIndex); + await child.Cancelled.WaitAsync(TimeSpan.FromSeconds(10), token); + } + + private static ConversationSessionFactory BuildFactory(string yaml, IChatClient parent, IChatClient child, IConversationStore? store = null) { RoutingChatClientFactory chatClients = new(parent); _ = chatClients.Route("parent", parent); _ = chatClients.Route("blocker", child); CompiledAgent compiled = ConfigurationCompiler.CompileAll( - ConfigurationLoader.LoadYaml(yaml), new AgentCompilationContext(chatClients))["main"]; + ConfigurationLoader.LoadYaml(yaml), new AgentCompilationContext(chatClients) { ConversationStore = store })["main"]; return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)); } diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs new file mode 100644 index 00000000..845915dc --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionCatchUpReleaseTests.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +using System.Text.Json; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime.Harness +{ + /// + /// A session that catches up on another host's turn swaps its MAF session for one read from the store, and + /// releases the old one. The turn does not wait for that release, and no snapshot sees half of the swap. + /// + public sealed class ConversationSessionCatchUpReleaseTests + { + private const string LingeringChildYaml = + """ + apiVersion: agentcore/v1 + guards: + never: { "<": [ { var: turnIndex }, 0 ] } + agents: + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: parent, to: [ { stage: done, when: never } ] } + - { id: done, agent: blocker, terminal: true } + """; + + private const string LingeringTodosYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, instructions: "delegate work", todos: true, model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + agent: parent + """; + + private const int BackgroundReleaseTimedOut = 43; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task ACatchUp_WithAChildThatIgnoresItsCancel_DoesNotHoldTheTurnForTheRelease() + { + InMemoryConversationStore store = new(); + using DeafChatClient child = new(); + await using ConversationSession a = Build(new ToolCallingChatClient("started", StartArgs()), child, store).Create("conversation-1"); + await using ConversationSession b = Build(new ScriptedChatClient("fine"), new DeafChatClient(), store).Create("conversation-1"); + + await StartAChildThenLetTheOtherHostTakeATurnAsync(a, b, child); + + // The release gives a deaf child 5 s before it gives up, so a turn that waited for it takes 5 s or more. + Stopwatch clock = Stopwatch.StartNew(); + TurnResult caughtUp = await a.RunTurnAsync("status?", Token); + clock.Stop(); + + Assert.Equal(2, caughtUp.TurnIndex); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(4), $"the catch-up turn took {clock.Elapsed.TotalMilliseconds:F0} ms"); + + child.Release(); + } + + [Fact] + public async Task DisposeAsync_WaitsForTheReleaseOfTheSessionACatchUpReplaced() + { + RecordingLoggerFactory logs = new(); + InMemoryConversationStore store = new(); + using DeafChatClient child = new(); + ConversationSession a = Build(new ToolCallingChatClient("started", StartArgs()), child, store, logs.CreateLogger("a")).Create("conversation-1"); + await using ConversationSession b = Build(new ScriptedChatClient("fine"), new DeafChatClient(), store).Create("conversation-1"); + + await StartAChildThenLetTheOtherHostTakeATurnAsync(a, b, child); + _ = await a.RunTurnAsync("status?", Token); + + await a.DisposeAsync(); + + CapturedLine timedOut = Assert.Single(logs.Of(BackgroundReleaseTimedOut)); + Assert.Equal(LogLevel.Warning, timedOut.Level); + Assert.Equal("conversation-1", timedOut.Field("ConversationId")); + } + + [Fact] + public async Task ASnapshotDuringACatchUp_NeverPairsTheOtherHostsProvidersWithTheOldTurnIndex() + { + InMemoryConversationStore store = new(); + using DeafChatClient child = new(); + GatedChatClient parent = new( + new ScriptedToolCallingChatClient(("background_agents_start_task", /*lang=json,strict*/ """{"agentName":"blocker","input":"work","description":"d"}""")) + { + FinalText = "started", + }); + ScriptedToolCallingChatClient other = new(("todos_add", /*lang=json,strict*/ """{"todos":[{"title":"from the other host"}]}""")) + { + FinalText = "fine", + }; + await using ConversationSession a = Build(parent, child, store, yaml: LingeringTodosYaml).Create("conversation-1"); + await using ConversationSession b = Build(other, new DeafChatClient(), store, yaml: LingeringTodosYaml).Create("conversation-1"); + + await StartAChildThenLetTheOtherHostTakeATurnAsync(a, b, child); + string own = Providers(a.Snapshot()); + ConversationRecord? record = await store.GetAsync("conversation-1", Token); + Assert.Equal(2, record!.State!.NextTurnIndex); + Assert.NotEqual(own, Providers(record.State)); + + parent.Arm(); + Task caughtUp = a.RunTurnAsync("status?", Token); + + // Snapshots taken from the turn's start until its model call: every one of them is either wholly before + // the catch-up or wholly after it. + List seen = []; + while (!parent.Entered.Task.IsCompleted && !caughtUp.IsCompleted) + { + seen.Add(a.Snapshot()); + await Task.Delay(TimeSpan.FromMilliseconds(1), Token); + } + + seen.Add(a.Snapshot()); + + Assert.All(seen, snapshot => Assert.Equal(snapshot.NextTurnIndex == 1, Providers(snapshot) == own)); + Assert.Equal(2, seen[^1].NextTurnIndex); + + parent.Open.SetResult(); + Assert.Equal(2, (await caughtUp).TurnIndex); + child.Release(); + } + + private static async Task StartAChildThenLetTheOtherHostTakeATurnAsync(ConversationSession a, ConversationSession b, DeafChatClient child) + { + _ = await a.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + _ = await b.RunTurnAsync("hello from the other host", Token); + } + + private static string Providers(ConversationSessionState state) + { + return JsonSerializer.Serialize(state.Providers); + } + + private static Dictionary StartArgs() + { + return new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }; + } + + private static ConversationSessionFactory Build( + IChatClient parent, IChatClient child, IConversationStore store, ILogger? logger = null, string yaml = LingeringChildYaml) + { + RoutingChatClientFactory chatClients = new(parent); + _ = chatClients.Route("parent", parent); + _ = chatClients.Route("blocker", child); + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), new AgentCompilationContext(chatClients) { ConversationStore = store })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards), logger: logger); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs new file mode 100644 index 00000000..6b5164bc --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDisposeRaceTests.cs @@ -0,0 +1,103 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime.Harness +{ + /// + /// A conversation disposed while a turn is on its way in: the dispose refuses a turn not yet admitted, and waits for + /// an admitted one to end before it releases, so no child of that turn outlives the conversation. + /// + public sealed class ConversationSessionDisposeRaceTests + { + private const string LingeringChildYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + agent: parent + """; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task DisposeWhileATurnReadsItsCatchUp_RefusesTheTurn_AndItRunsNothing() + { + GatedReadConversationStore store = new(); + using HangUntilCancelledChatClient child = new(); + ToolCallingChatClient parent = new("started", StartArgs()); + ConversationSession a = Build(parent, child, store).Create("conversation-1"); + await using ConversationSession b = Build(new ScriptedChatClient("fine"), new HangUntilCancelledChatClient(), store).Create("conversation-1"); + + _ = await a.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + _ = await b.RunTurnAsync("hello from the other host", Token); + int calls = parent.Calls; + + store.Arm(); + Task turn = a.RunTurnAsync("status?", Token); + await store.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + await a.DisposeAsync(); + store.Open.SetResult(); + + _ = await Assert.ThrowsAsync(() => turn); + Assert.True(child.Cancelled.IsCompleted); + Assert.Equal(calls, parent.Calls); + } + + [Fact] + public async Task DisposeWhileATurnRuns_WaitsForTheTurn_ThenCancelsTheChildItStarted() + { + InMemoryConversationStore store = new(); + using HangUntilCancelledChatClient child = new(); + GatedChatClient parent = new(new AfterTaskChatClient(new ToolCallingChatClient("started", StartArgs()), child.Started)); + ConversationSession a = Build(parent, child, store).Create("conversation-1"); + + parent.Arm(); + Task turn = a.RunTurnAsync("go", Token); + await parent.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + Task disposed = a.DisposeAsync().AsTask(); + parent.Open.SetResult(); + + Assert.Equal("started", (await turn).ReplyText); + await disposed.WaitAsync(TimeSpan.FromSeconds(10), Token); + await child.Cancelled.WaitAsync(TimeSpan.FromSeconds(10), Token); + } + + private static Dictionary StartArgs() + { + return new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }; + } + + private static ConversationSessionFactory Build(IChatClient parent, IChatClient child, IConversationStore store) + { + RoutingChatClientFactory chatClients = new(parent); + _ = chatClients.Route("parent", parent); + _ = chatClients.Route("blocker", child); + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(LingeringChildYaml), new AgentCompilationContext(chatClients) { ConversationStore = store })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs new file mode 100644 index 00000000..5b08f5f3 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionDroppedWriteTests.cs @@ -0,0 +1,324 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Compaction; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Transcript.ConversationSessionCompactionTestSupport; + +namespace AgentCore.Application.Tests.Runtime.Harness +{ + /// + /// A store 1 append that fails, or that the store saved and then reported failed, while no other session writes the + /// conversation: the next turn takes the store's words back and keeps the session's own harness state, which its + /// commit then writes again. Only a store that another session wrote in the meantime replaces that state (the P1 + /// case in ). + /// + public sealed class ConversationSessionDroppedWriteTests + { + private const string TodosYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "track todos", todos: true } + entries: + main: + agent: only + """; + + private const string LingeringChildYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + agent: parent + """; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task OneDroppedAppend_WithNoOtherWriter_KeepsTheTodoThatTurnAdded_AndWritesItAtTheNextCommit() + { + FlakyAppendConversationStore store = new(); + CueToolChatClient model = new("track", () => new(StringComparer.Ordinal) + { + ["todos"] = new List { new Dictionary(StringComparer.Ordinal) { ["title"] = "buy milk" } }, + }); + await using ConversationSession session = Build(TodosYaml, store, ("only", model)).Create("conversation-1"); + + _ = await session.RunTurnAsync("hello", Token); + store.Down = true; + _ = await session.RunTurnAsync("please track this", Token); + await session.FlushTranscriptAsync(); + store.Down = false; + + _ = await session.RunTurnAsync("what's on my list", Token); + await session.FlushTranscriptAsync(); + + Assert.Contains(model.Requests[^1], message => message.Text.Contains("buy milk", StringComparison.Ordinal)); + ConversationRecord? record = await store.GetAsync("conversation-1", Token); + Assert.Contains("buy milk", record!.State!.Providers[new TodoProvider().StateKeys[0]].GetRawText(), StringComparison.Ordinal); + } + + [Fact] + public async Task OneDroppedAppend_WithNoOtherWriter_KeepsTheSession_AndItsBackgroundChildRunning() + { + FlakyAppendConversationStore store = new(); + using HangUntilCancelledChatClient child = new(); + ToolCallingChatClient parent = new("started", new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }); + await using ConversationSession session = Build(LingeringChildYaml, store, ("parent", parent), ("blocker", child)).Create("conversation-1"); + + _ = await session.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + store.Down = true; + _ = await session.RunTurnAsync("again", Token); + await session.FlushTranscriptAsync(); + store.Down = false; + AgentSession? before = session.Ledger.Session(); + + TurnResult next = await session.RunTurnAsync("status?", Token); + + Assert.Equal(2, next.TurnIndex); + Assert.Same(before, session.Ledger.Session()); + Assert.False(child.Cancelled.IsCompleted); + } + + [Fact] + public async Task OneAppendTheStoreSavedButReportedFailed_WithNoOtherWriter_KeepsTheSession_AndItsBackgroundChildRunning() + { + FlakyAppendConversationStore store = new(); + using HangUntilCancelledChatClient child = new(); + ToolCallingChatClient parent = new("started", new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }); + await using ConversationSession session = Build(LingeringChildYaml, store, ("parent", parent), ("blocker", child)).Create("conversation-1"); + + _ = await session.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + store.SavesThenFails = true; + _ = await session.RunTurnAsync("again", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + AgentSession? before = session.Ledger.Session(); + + TurnResult next = await session.RunTurnAsync("status?", Token); + await session.FlushTranscriptAsync(); + + Assert.Equal(2, next.TurnIndex); + Assert.Same(before, session.Ledger.Session()); + Assert.False(child.Cancelled.IsCompleted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ALostAppend_ThenAnEditUnderTheSummaryInATurnWhoseCatchUpCouldNotRead_KeepsTheSession(bool savedThenFailed) + { + FlakyAppendConversationStore store = new(); + _ = await store.CreateAsync("c1", Token); + await SeedPlainTurnAsync(store, turnIndex: 0, "q0", "a0"); + await SeedPlainTurnAsync(store, turnIndex: 1, "q1", "a1"); + await SeedPlainTurnAsync(store, turnIndex: 2, "q2", "a2"); + await using ConversationSession session = Build( + TodosYaml, store, Summary(new ScriptedChatClient("the gist of it"), minimumPreservedGroups: 0), new ScriptedChatClient("ok")).Create("c1"); + + _ = await session.RunTurnAsync("q3", Token); + store.SavesThenFails = savedThenFailed; + store.Down = !savedThenFailed; + _ = await session.RunTurnAsync("q4", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + store.Down = false; + + // "m-0-a" is under the summary, so the edit cuts the store and reads the session's words back from it. + store.RecordReadsToFail = 1; + _ = await session.RunTurnAtOriginAsync("q0, rewritten", new ConversationTurnOrigin("caller-2", "m-0-a") { NamesParent = true }, Token); + await session.FlushTranscriptAsync(); + Assert.Equal(0, store.RecordReadsToFail); + AgentSession? before = session.Ledger.Session(); + + _ = await session.RunTurnAsync("q5", Token); + + Assert.Same(before, session.Ledger.Session()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ALostAppend_ThenALateCutThatRemovesItsReply_WithNoOtherWriter_KeepsTheSession_AndItsBackgroundChildRunning(bool savedThenFailed) + { + FlakyAppendConversationStore store = new(); + using HangUntilCancelledChatClient child = new(); + ToolCallingChatClient parent = new("started", new(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }); + await using ConversationSession session = Build(LingeringChildYaml, store, ("parent", parent), ("blocker", child)).Create("conversation-1"); + + _ = await session.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + store.SavesThenFails = savedThenFailed; + store.Down = !savedThenFailed; + _ = await session.RunTurnAsync("again", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + store.Down = false; + + // A barge-in before any word of turn 1's reply played leaves its reply row empty, so the session deletes it. + Assert.True(session.Cut(1, new TurnCut(string.Empty, null))); + await session.FlushTranscriptAsync(); + AgentSession? before = session.Ledger.Session(); + + TurnResult next = await session.RunTurnAsync("status?", Token); + await session.FlushTranscriptAsync(); + + Assert.Equal(2, next.TurnIndex); + Assert.Same(before, session.Ledger.Session()); + Assert.False(child.Cancelled.IsCompleted); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 2)] + [InlineData(true, 2)] + public async Task ALostAppend_ThenAnInSessionEditWithdrawingIt_InATurnWhoseCatchUpCouldNotRead_KeepsTheSession(bool savedThenFailed, int readsThatFail) + { + FlakyAppendConversationStore store = new(); + _ = await store.CreateAsync("c1", Token); + await SeedPlainTurnAsync(store, turnIndex: 0, "q0", "a0"); + await SeedPlainTurnAsync(store, turnIndex: 1, "q1", "a1"); + await using ConversationSession session = Build(TodosYaml, store, ("only", new ScriptedChatClient("ok"))).Create("c1"); + + _ = await session.RunTurnAsync("q2", Token); + store.SavesThenFails = savedThenFailed; + store.Down = !savedThenFailed; + _ = await session.RunTurnAsync("q3", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + store.Down = false; + + // "m-1-a" is under no summary, so the session truncates its own words from turn 2 on. The second failed + // read is the one that would judge the lost append before the truncate deletes every row of it. + store.RecordReadsToFail = readsThatFail; + _ = await session.RunTurnAtOriginAsync("q2, rewritten", new ConversationTurnOrigin("caller-2", "m-1-a") { NamesParent = true }, Token); + await session.FlushTranscriptAsync(); + Assert.Equal(0, store.RecordReadsToFail); + AgentSession? before = session.Ledger.Session(); + + _ = await session.RunTurnAsync("q5", Token); + + Assert.Same(before, session.Ledger.Session()); + } + + [Fact] + public async Task ADroppedAppend_ThenAnotherSessionsTurnInItsPlace_ThenAnInSessionEditWithdrawingBoth_StillRebuildsTheSession() + { + FlakyAppendConversationStore store = new(); + _ = await store.CreateAsync("c1", Token); + await SeedPlainTurnAsync(store, turnIndex: 0, "q0", "a0"); + await SeedPlainTurnAsync(store, turnIndex: 1, "q1", "a1"); + await using ConversationSession session = Build(TodosYaml, store, ("only", new ScriptedChatClient("ok"))).Create("c1"); + await using ConversationSession other = Build(TodosYaml, store, ("only", new ScriptedChatClient("ok"))).Create("c1"); + + _ = await session.RunTurnAsync("q2", Token); + store.Down = true; + _ = await session.RunTurnAsync("q3", Token); + await session.FlushTranscriptAsync(); + store.Down = false; + + // The other session saves its own turn 3, with as many rows as the lost one, where the lost one would stand. + _ = await other.RunTurnAsync("q3 from elsewhere", Token); + await other.FlushTranscriptAsync(); + + store.RecordReadsToFail = 1; + _ = await session.RunTurnAtOriginAsync("q2, rewritten", new ConversationTurnOrigin("caller-2", "m-1-a") { NamesParent = true }, Token); + await session.FlushTranscriptAsync(); + Assert.Equal(0, store.RecordReadsToFail); + AgentSession? before = session.Ledger.Session(); + + _ = await session.RunTurnAsync("q5", Token); + + Assert.NotSame(before, session.Ledger.Session()); + } + + [Fact] + public async Task OneAppendTheStoreSavedButReportedFailed_ThenASummaryOverItWhileTheStoreCannotBeRead_KeepsTheSession() + { + FlakyAppendConversationStore store = new(); + _ = await store.CreateAsync("c1", Token); + bool compacting = false; +#pragma warning disable MAAI001 // Compaction is evaluation-only in Microsoft.Agents.AI 1.21.0. + CompactionStages compaction = SummaryOnly( + new ScriptedChatClient("the gist of it"), client => new SummarizationCompactionStrategy(client, _ => compacting, minimumPreservedGroups: 0)); +#pragma warning restore MAAI001 + await using ConversationSession session = Build(TodosYaml, store, compaction, new ScriptedChatClient("ok")).Create("c1"); + + _ = await session.RunTurnAsync("q0", Token); + store.SavesThenFails = true; + _ = await session.RunTurnAsync("q1", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + + // The second of these summaries covers turn 1, so the rows the store saved are no longer read for the session. + compacting = true; + store.RecordReadsToFail = 2; + _ = await session.RunTurnAsync("q2", Token); + _ = await session.RunTurnAsync("q3", Token); + await session.FlushTranscriptAsync(); + Assert.Equal(0, store.RecordReadsToFail); + Assert.DoesNotContain(await store.ReadForSessionAsync("c1", Token), row => row.Content.Text == "q1"); + AgentSession? before = session.Ledger.Session(); + + _ = await session.RunTurnAsync("q4", Token); + + Assert.Same(before, session.Ledger.Session()); + } + + private static ConversationSessionFactory Build(string yaml, IConversationStore store, CompactionStages compaction, IChatClient model) + { + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), + new AgentCompilationContext(new RoutingChatClientFactory(model)) { ConversationStore = store, Compaction = compaction })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + } + + private static ConversationSessionFactory Build(string yaml, IConversationStore store, params (string Ref, IChatClient Client)[] routes) + { + RoutingChatClientFactory chatClients = new(routes[0].Client); + foreach ((string name, IChatClient client) in routes) + { + _ = chatClients.Route(name, client); + } + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(yaml), new AgentCompilationContext(chatClients) { ConversationStore = store })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs index 6bd3f932..67d3fc37 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionGraphStateTests.cs @@ -96,6 +96,26 @@ public async Task ResumeFromTheSameStore_AdderRequestCarriesTheRestoredTodo() Assert.Contains(resumed, message => message.Text.Contains("buy milk", StringComparison.Ordinal)); } + [Fact] + public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_ResumesTheWorkflowThatTurnStored_AndKeepsIt() + { + InMemoryConversationStore store = new(); + RequestCapturingChatClient adder = new(new TodoFromCallerChatClient()); + ConversationSessionFactory factory = Build(TodosYaml, store, adder, new ScriptedToolCallingChatClient() { FinalText = "echo" }); + ConversationSession a = factory.Create("conversation-1"); + ConversationSession b = factory.Create("conversation-1"); + + _ = await a.RunTurnAsync("add buy milk", TestContext.Current.CancellationToken); + _ = await b.RunTurnAsync("add call the bank", TestContext.Current.CancellationToken); + _ = await a.RunTurnAsync("anything new?", TestContext.Current.CancellationToken); + await a.FlushTranscriptAsync(); + + Assert.Contains(adder.Requests[^1], message => message.Text.Contains("call the bank", StringComparison.Ordinal)); + ConversationRecord? record = await store.GetAsync("conversation-1", TestContext.Current.CancellationToken); + Assert.Equal(3, record?.State?.NextTurnIndex); + Assert.Contains("call the bank", record!.State!.WorkflowState!.Value.GetRawText(), StringComparison.Ordinal); + } + [Fact] public async Task NoHarnessSwitch_AfterATurn_Store0HoldsNeitherBlobNorProviders() { diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs index 13202731..69142a76 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionProviderStateTests.cs @@ -7,6 +7,7 @@ using AgentCore.Application.Configuration.Validation; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; using AgentCore.Application.Tests.Fakes; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -84,16 +85,64 @@ public async Task ASecondSessionFromTheSameFactoryAndStore_ReInjectsTheTodo() message => message.Text.Contains("buy milk", StringComparison.Ordinal)); } + [Fact] + public async Task ASessionThatCaughtUpOnAnotherHostsTurn_RunsOnTheTodoThatTurnStored_AndKeepsIt() + { + InMemoryConversationStore store = new(); + SequencedChatClient hostA = new("a0", "a2"); + CompiledAgent compiledA = Compile(TodosYaml, hostA, store); + CompiledAgent compiledB = Compile(TodosYaml, new TodoAddThenTextChatClient(), store); + ConversationSession a = new ConversationSessionFactory(compiledA, new GuardEvaluator(compiledA.Configuration.Guards)).Create("conversation-1"); + ConversationSession b = new ConversationSessionFactory(compiledB, new GuardEvaluator(compiledB.Configuration.Guards)).Create("conversation-1"); + + _ = await a.RunTurnAsync("hello", TestContext.Current.CancellationToken); + _ = await b.RunTurnAsync("please track this", TestContext.Current.CancellationToken); + _ = await a.RunTurnAsync("what's on my list", TestContext.Current.CancellationToken); + await a.FlushTranscriptAsync(); + + Assert.Contains(hostA.Requests[^1], message => message.Text.Contains("buy milk", StringComparison.Ordinal)); + ConversationRecord? record = await store.GetAsync("conversation-1", TestContext.Current.CancellationToken); + Assert.Equal(3, record?.State?.NextTurnIndex); + Assert.Contains("buy milk", record!.State!.Providers[new TodoProvider().StateKeys[0]].GetRawText(), StringComparison.Ordinal); + } + + [Fact] + public async Task ASessionWhoseTurnTheStoreDropped_RunsOnTheTodoAnotherHostSavedUnderThatTurn_AndKeepsIt() + { + FlakyAppendConversationStore store = new(); + SequencedChatClient hostA = new("a0", "a1", "a2"); + CompiledAgent compiledA = Compile(TodosYaml, hostA, store); + CompiledAgent compiledB = Compile(TodosYaml, new TodoAddThenTextChatClient(), store); + ConversationSession a = new ConversationSessionFactory(compiledA, new GuardEvaluator(compiledA.Configuration.Guards)).Create("conversation-1"); + ConversationSession b = new ConversationSessionFactory(compiledB, new GuardEvaluator(compiledB.Configuration.Guards)).Create("conversation-1"); + + _ = await a.RunTurnAsync("hello", TestContext.Current.CancellationToken); + store.Down = true; + _ = await a.RunTurnAsync("this turn is lost", TestContext.Current.CancellationToken); + await a.FlushTranscriptAsync(); + store.Down = false; + + // B opens on the store, which never saw A's turn 1, so B saves its own turn 1. + _ = await b.RunTurnAsync("please track this", TestContext.Current.CancellationToken); + _ = await a.RunTurnAsync("what's on my list", TestContext.Current.CancellationToken); + await a.FlushTranscriptAsync(); + + Assert.Contains(hostA.Requests[^1], message => message.Text.Contains("buy milk", StringComparison.Ordinal)); + ConversationRecord? record = await store.GetAsync("conversation-1", TestContext.Current.CancellationToken); + Assert.Equal(3, record?.State?.NextTurnIndex); + Assert.Contains("buy milk", record!.State!.Providers[new TodoProvider().StateKeys[0]].GetRawText(), StringComparison.Ordinal); + } + [Fact] public async Task AResumeFromAHostCheckpoint_ThroughAgentCoreAgent_ReInjectsTheTodo() { InMemoryConversationStore firstStore = new(); TodoAddThenTextChatClient firstChatClient = new(); CompiledAgent firstCompiled = Compile(TodosYaml, firstChatClient, firstStore); - AgentCoreAgent firstAgent = new( - new ConversationSessionFactory(firstCompiled, new GuardEvaluator(firstCompiled.Configuration.Guards)), "main"); + AgentCoreAgent firstAgent = AgentOver( + new ConversationSessionFactory(firstCompiled, new GuardEvaluator(firstCompiled.Configuration.Guards))); - AgentSession firstSession = await firstAgent.CreateSessionAsync("conversation-1"); + AgentSession firstSession = await firstAgent.CreateSessionAsync("conversation-1", TestContext.Current.CancellationToken); _ = await firstAgent.RunAsync( "please track this", firstSession, cancellationToken: TestContext.Current.CancellationToken); @@ -105,8 +154,8 @@ public async Task AResumeFromAHostCheckpoint_ThroughAgentCoreAgent_ReInjectsTheT InMemoryConversationStore secondStore = new(); SequencedChatClient secondChatClient = new("hello there."); CompiledAgent secondCompiled = Compile(TodosYaml, secondChatClient, secondStore); - AgentCoreAgent secondAgent = new( - new ConversationSessionFactory(secondCompiled, new GuardEvaluator(secondCompiled.Configuration.Guards)), "main"); + AgentCoreAgent secondAgent = AgentOver( + new ConversationSessionFactory(secondCompiled, new GuardEvaluator(secondCompiled.Configuration.Guards))); AgentSession revived = await secondAgent.DeserializeSessionAsync( serialized, cancellationToken: TestContext.Current.CancellationToken); @@ -170,6 +219,16 @@ private static CompiledAgent Compile(string yaml, IChatClient chatClient, IConve new AgentCompilationContext(new FakeChatClientFactory(chatClient)) { ConversationStore = store })["main"]; } + private static AgentCoreAgent AgentOver(IConversationSessionFactory factory) + { + InMemoryConversationSessions sessions = new( + new Dictionary(StringComparer.Ordinal) { ["main"] = factory }, + TimeSpan.FromMinutes(30), + TimeProvider.System); + + return new AgentCoreAgent(sessions, "main"); + } + /// /// Calls todos_add {"todos":[{"title":"buy milk"}]} on the very first request this /// instance ever answers, then answers text on every request after — regardless of whether the diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs index 2e052bfd..43dcd491 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationSessionShellTests.cs @@ -10,6 +10,7 @@ using Microsoft.Agents.AI.Tools.Shell; using Microsoft.Extensions.AI; using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Runtime.Harness { @@ -119,24 +120,68 @@ public async Task AfterTheConversationIsClosed_TheEchoPidIsGoneFromProc() { ShellScriptedClient client = new(RunShellToolName, "pwd", "rm -rf x", "echo $$"); ConversationSessionFactory factory = BuildFactory(ShellYaml, client); - InMemoryConversationSessions sessions = new(factory, TimeSpan.FromMinutes(30), TimeProvider.System); + InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), TimeSpan.FromMinutes(30), TimeProvider.System); - ConversationSession session = await sessions.OpenAsync("conversation-1", TestContext.Current.CancellationToken); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); _ = await session.RunTurnAsync("go", TestContext.Current.CancellationToken); - int pid = ParsePid(client.ToolResults[2]); + int pid = ProcHelpers.ParsePid(client.ToolResults[2]); Assert.True(Directory.Exists($"/proc/{pid}"), "the bash should be alive before the conversation closes"); - await sessions.CloseAsync("conversation-1", TestContext.Current.CancellationToken); + await sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", TestContext.Current.CancellationToken); await ProcHelpers.AssertGoneAsync(pid, TestContext.Current.CancellationToken); } + [Fact] + public async Task AfterTheConversationIsClosed_ItsWorkspaceFolderIsGone() + { + // CloseAsync must delete the folder by itself: a caller may close without ending the conversation. + ShellScriptedClient client = new(RunShellToolName, "echo $$"); + ConversationSessionFactory factory = BuildFactory(ShellYaml, client); + InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), TimeSpan.FromMinutes(30), TimeProvider.System); + + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); + string workspace = session.Workspace!; + Assert.True(Directory.Exists(workspace)); + _ = await session.RunTurnAsync("go", TestContext.Current.CancellationToken); + + await sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", TestContext.Current.CancellationToken); + + Assert.False(Directory.Exists(workspace)); + } + + [Fact] + public async Task AnIdleUnload_DisposesTheShellAndDeletesTheWorkspaceFolder() + { + ShellScriptedClient client = new(RunShellToolName, "echo $$"); + ConversationSessionFactory factory = BuildFactory(ShellYaml, client); + FakeTimeProvider clock = Clock(); + InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(factory), InMemoryConversationSessions.DefaultIdleTimeout, clock); + + ConversationSession session = await sessions.GetOrOpenAsync( + SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); + _ = await session.RunTurnAsync("go", TestContext.Current.CancellationToken); + + int pid = ProcHelpers.ParsePid(client.ToolResults[0]); + string workspace = session.Workspace!; + Assert.True(Directory.Exists(workspace)); + + clock.Advance(InMemoryConversationSessions.DefaultIdleTimeout); + + // The idle timer starts the unload on a background task that this call does not await. + // Wait for the teardown itself first — the workspace delete is its last step — then give + // the pid its own short budget for the OS to reap it, rather than race the two. + await EventuallyAsync(() => !Directory.Exists(workspace)); + await ProcHelpers.AssertGoneAsync(pid, TestContext.Current.CancellationToken); + } + [Fact] public async Task CloseAsync_WhenTheStoreThrowsOnFlush_StillDisposesTheShell() { // AgentCoreChatHistoryProvider.WriteAfterAsync catches every exception a store write - // raises and reports it through TranscriptWriteDropped, whose own contract is that it + // raises and reports it through ITranscriptLossCounter.Dropped, whose own contract is that it // never throws back out — "the conversation outlives a store that refuses" — so FlushTranscriptAsync // itself surfaces nothing here, which is today's behaviour: CloseAsync completes rather than // throw. What this proves is the part that used to be at risk: even though the flush ate the @@ -150,15 +195,17 @@ public async Task CloseAsync_WhenTheStoreThrowsOnFlush_StillDisposesTheShell() new AgentCompilationContext(chatClients) { WorkspaceRoot = _root, ConversationStore = store })["main"]; ConversationSessionFactory factory = new( compiled, new GuardEvaluator(compiled.Configuration.Guards), workspaceRoot: _root); - InMemoryConversationSessions sessions = new(factory, TimeSpan.FromMinutes(30), TimeProvider.System); + InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), TimeSpan.FromMinutes(30), TimeProvider.System); - ConversationSession session = await sessions.OpenAsync("conversation-1", TestContext.Current.CancellationToken); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); + string workspace = session.Workspace!; _ = await session.RunTurnAsync("go", TestContext.Current.CancellationToken); - int pid = ParsePid(client.ToolResults[0]); + int pid = ProcHelpers.ParsePid(client.ToolResults[0]); - await sessions.CloseAsync("conversation-1", TestContext.Current.CancellationToken); + await sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", TestContext.Current.CancellationToken); await ProcHelpers.AssertGoneAsync(pid, TestContext.Current.CancellationToken); + Assert.False(Directory.Exists(workspace), "a close over a store that refuses its writes must still delete the workspace folder"); } [Fact] @@ -174,8 +221,8 @@ public async Task ASecondConversationFromTheSameFactory_GetsADifferentPid() await using ConversationSession sessionB = factory.Create("conversation-b"); _ = await sessionB.RunTurnAsync("go", TestContext.Current.CancellationToken); - int pidA = ParsePid(client.ToolResults[0]); - int pidB = ParsePid(client.ToolResults[1]); + int pidA = ProcHelpers.ParsePid(client.ToolResults[0]); + int pidB = ProcHelpers.ParsePid(client.ToolResults[1]); Assert.NotEqual(pidA, pidB); } @@ -205,7 +252,7 @@ public async Task ATurnThatReachesATerminalStage_DisposesTheShellWithoutCloseAsy TurnResult turn = await session.RunTurnAsync("go", TestContext.Current.CancellationToken); Assert.True(turn.IsTerminal); - int pid = ParsePid(client.ToolResults[0]); + int pid = ProcHelpers.ParsePid(client.ToolResults[0]); await ProcHelpers.AssertGoneAsync(pid, TestContext.Current.CancellationToken); } @@ -244,15 +291,6 @@ public async Task ASecondConversation_GetsItsOwnWorkspaceInItsInstructions() Assert.DoesNotContain(sessionB.Workspace!, instructionsA, StringComparison.Ordinal); } - /// - /// Reads the pid off the first line of a run_shell tool result: MAF's own formatting - /// appends an exit_code: line after the command's stdout. - /// - private static int ParsePid(string toolResult) - { - return int.Parse(toolResult.Split('\n', StringSplitOptions.TrimEntries)[0]); - } - private static string ToolName() { string workspace = Directory.CreateTempSubdirectory("shell-tool-name-").FullName; @@ -284,6 +322,5 @@ private ConversationSessionFactory BuildFactory(string yaml, IChatClient client) new GuardEvaluator(compiled.Configuration.Guards), workspaceRoot: _root); } - } } diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellProviderTests.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellProviderTests.cs new file mode 100644 index 00000000..d700a187 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ConversationShellProviderTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime.Harness; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime.Harness +{ +#pragma warning disable MAAI001 // AIContextProvider.InvokingContext is evaluation-only in Microsoft.Agents.AI 1.21.0. + + /// + /// and as units: + /// with no turn filed on the invoking session (a delegated agent's run), both serve an + /// empty context instead of throwing (design §6.3#6, inverse of probe P11c). + /// + public sealed class ConversationShellProviderTests + { + [Fact] + public async Task ConversationShellProvider_WithoutATurn_ServesAnEmptyContext() + { + ConversationShellProvider provider = new(Options()); + + AIContext context = await provider.InvokingAsync(Invoking(), TestContext.Current.CancellationToken); + + Assert.Null(context.Tools); + } + + [Fact] + public async Task ConversationShellEnvironmentProvider_WithoutATurn_ServesAnEmptyContext() + { + ConversationShellEnvironmentProvider provider = new(Options()); + + AIContext context = await provider.InvokingAsync(Invoking(), TestContext.Current.CancellationToken); + + Assert.Null(context.Instructions); + } + + private static ConversationShellOptions Options() + { + return new(ShellKind.Docker, null, null, null, null, null, null, null); + } + + private static AIContextProvider.InvokingContext Invoking() + { + return new(StubAgent.Instance, new StubSession(), new AIContext()); + } + + private sealed class StubSession : AgentSession; + + private sealed class StubAgent : AIAgent + { + public static StubAgent Instance { get; } = new(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) + { + return new(new StubSession()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + } + +#pragma warning restore MAAI001 +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/ProcHelpers.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/ProcHelpers.cs index 444125dd..f4b20b40 100644 --- a/tests/AgentCore.Application.Tests/Runtime/Harness/ProcHelpers.cs +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/ProcHelpers.cs @@ -28,9 +28,27 @@ public static async Task AssertGoneAsync(int pid, CancellationToken cancellation Assert.True(IsGone(pid), $"the bash pid {pid} should be gone (or a zombie) within {Timeout}"); } + /// + /// Reads the pid off the first line of a run_shell tool result: MAF's own formatting + /// appends an exit_code: line after the command's stdout. + /// + public static int ParsePid(string toolResult) + { + return int.Parse(toolResult.Split('\n', StringSplitOptions.TrimEntries)[0]); + } + private static bool IsGone(int pid) { - return !Directory.Exists($"/proc/{pid}") || File.ReadAllText($"/proc/{pid}/stat").Contains(" Z "); + // A single read, not an existence check followed by a read: the pid can vanish between the + // two, and the kernel then fails the read (ESRCH) instead of returning ENOENT for it. + try + { + return File.ReadAllText($"/proc/{pid}/stat").Contains(" Z "); + } + catch (IOException) + { + return true; + } } } } diff --git a/tests/AgentCore.Application.Tests/Runtime/Harness/TodoFromCallerChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Harness/TodoFromCallerChatClient.cs new file mode 100644 index 00000000..d36ffaa0 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Harness/TodoFromCallerChatClient.cs @@ -0,0 +1,76 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime.Harness +{ + /// + /// Adds a todo when the caller's latest words are add <title>, and answers added once the tool + /// answered or when the caller asked anything else. It keys on the request alone, so any number of sessions can + /// share one instance in any order. + /// + internal sealed class TodoFromCallerChatClient : IChatClient + { + private const string Add = "add "; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List request = [.. messages]; + await Task.Yield(); + string id = Guid.NewGuid().ToString("N"); + + ChatMessage? said = request.LastOrDefault(message => message.Role == ChatRole.User && !message.Text.StartsWith('#')); + bool answered = request.Count > 0 && request[^1].Role == ChatRole.Tool; + AIFunction? tool = options?.Tools?.OfType().FirstOrDefault(function => function.Name == "todos_add"); + + if (!answered && tool is not null && said is not null && said.Text.StartsWith(Add, StringComparison.Ordinal)) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent( + id, + tool.Name, + new Dictionary(StringComparer.Ordinal) + { + ["todos"] = new List + { + new Dictionary(StringComparer.Ordinal) { ["title"] = said.Text[Add.Length..] }, + }, + }), + ]) + { + ResponseId = id, + MessageId = id, + }; + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, "added") { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs b/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs new file mode 100644 index 00000000..6c8a8801 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/HarnessTurn.cs @@ -0,0 +1,55 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// One turn as the runner files it: the invocation, its completer and its cut slot. + internal sealed class HarnessTurn + { + private HarnessTurn(TurnInvocation invocation, RecordingTurnCompleter completer, TurnCutSlot slot) + { + Invocation = invocation; + Completer = completer; + Slot = slot; + } + + public TurnInvocation Invocation { get; } + + public RecordingTurnCompleter Completer { get; } + + public TurnCutSlot Slot { get; } + + public List Updates { get; set; } = []; + + public static HarnessTurn Open( + AgentCoreChatHistoryProvider history, + AgentSession historySession, + string text, + int turnIndex, + bool carriesHistory, + List log, + ConversationTurnOrigin? origin = null, + CancellationTokenSource? host = null) + { + RecordingTurnCompleter completer = new(history, historySession, log); + TurnCutSlot slot = new(new Lock(), host?.Token ?? TestContext.Current.CancellationToken); + TurnInvocation invocation = new() + { + ConversationId = ConversationTurnAgentHarness.ConversationId, + TurnIndex = turnIndex, + Stage = string.Empty, + CarriesHistory = carriesHistory, + HistorySession = historySession, + User = new ChatMessage(ChatRole.User, text), + Origin = origin, + Completer = completer, + CutSlot = slot, + }; + return new HarnessTurn(invocation, completer, slot); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedExtractorChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedExtractorChatClient.cs new file mode 100644 index 00000000..f8b78177 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedExtractorChatClient.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Reports that the extractor call is in flight, then holds it until the test lets it answer. + /// + internal sealed class GatedExtractorChatClient : IChatClient + { + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a task that completes once the extractor's own model call is in flight. + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Lets the extractor answer. + public void OpenGate() + { + _ = _gate.TrySetResult(); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + _ = Started.TrySetResult(); + await _gate.Task.ConfigureAwait(false); + + // An empty document fills no slot, which is all this fake owes the writer order. + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "{}")); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // StateExtractor.ExtractAsync calls GetResponseAsync and never streams. + throw new NotSupportedException("The extractor calls GetResponseAsync, and never streams."); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs new file mode 100644 index 00000000..22dd452f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/GatedToolThenReplyChatClient.cs @@ -0,0 +1,91 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Tests.Fakes; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Calls the one tool it is offered, then streams its final reply and blocks after the first + /// fragment until the test opens the gate. + /// + internal sealed class GatedToolThenReplyChatClient(string reply) : IChatClient + { + private const string ToolCallId = "conversation_1"; + + private readonly string[] _fragments = reply.Split(' '); + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Lets every fragment after the first flow. + public void OpenGate() + { + _ = _gate.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + await Task.Yield(); + + bool answered = messages.Any(message => message.Contents.OfType().Any()); + string responseId = Guid.NewGuid().ToString("N"); + + if (!answered && options?.Tools?.OfType().FirstOrDefault() is { } tool) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent(ToolCallId, tool.Name, new Dictionary(StringComparer.Ordinal))]) + { + ResponseId = responseId, + MessageId = responseId, + }; + + yield break; + } + + for (int index = 0; index < _fragments.Length; index++) + { + if (index == 1) + { + // The tool round already finished, so this is the round a barge-in interrupts. + await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + string text = index == 0 ? _fragments[index] : " " + _fragments[index]; + yield return new ChatResponseUpdate(ChatRole.Assistant, text) + { + ResponseId = responseId, + MessageId = responseId, + }; + } + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/HangingChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/HangingChatClient.cs new file mode 100644 index 00000000..fd4d6eaf --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/HangingChatClient.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// Answers nothing, ever. It stands in for the extractor model hanging past its deadline. + internal sealed class HangingChatClient : IChatClient + { + private readonly TaskCompletionSource _neverSet = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + await _neverSet.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Unreachable: nothing ever completes _neverSet, so the wait above only ever cancels. + return new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty)); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + // StateExtractor.ExtractAsync calls GetResponseAsync and never streams. + throw new NotSupportedException("The extractor calls GetResponseAsync, and never streams."); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs new file mode 100644 index 00000000..4e158082 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionFixture.cs @@ -0,0 +1,157 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Runtime.InterruptionSessions; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Drives one streaming turn on a background task, far enough to interrupt it. + /// + internal sealed class InterruptionFixture : IAsyncDisposable + { + private readonly CancellationTokenSource _cancellation; + private readonly IReadOnlyList _disposables; + private readonly Action _openGate; + private readonly TaskCompletionSource _firstUpdate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _run; + + private InterruptionFixture(ConversationSession session, IChatClient reply, Action openGate, CancellationToken hostToken) + { + Session = session; + _disposables = [reply]; + _openGate = openGate; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(hostToken); + _cancellation.CancelAfter(TimeSpan.FromSeconds(30)); + // The run reads its own token, _cancellation.Token, from inside PumpAsync, so the token + // Task.Run offers here is deliberately unused. + _run = Task.Run(PumpAsync, CancellationToken.None); + } + + /// Builds a fixture over a turn that runs directly, with no streaming pump behind it. + private InterruptionFixture(ConversationSession session, IReadOnlyList disposables, CancellationToken hostToken) + { + Session = session; + _disposables = disposables; + _openGate = static () => { }; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(hostToken); + _cancellation.CancelAfter(TimeSpan.FromSeconds(30)); + // RunTurnAsync drives the turn itself, on the caller's own conversation. There is no round to + // stream and gate, so there is nothing here for a background pump to do. + _run = Task.CompletedTask; + } + + /// Gets the session the turn runs on. + public ConversationSession Session { get; } + + /// Starts a turn over a plain text reply, gated after its first fragment. + public static InterruptionFixture Start(string reply) + { + ScriptedChatClient client = new(reply.Split(' ')) { GateAfterFirstFragment = true }; + ConversationSession session = CreateSession(NoToolYaml, client); + return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); + } + + /// Starts a turn whose tool call and result finish before the reply that follows them. + public static InterruptionFixture StartWithFinishedTool(string reply) + { + GatedToolThenReplyChatClient client = new(reply); + ConversationSession session = CreateSession(ToolYaml, client, new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create); + return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); + } + + /// Starts a turn whose tool round also carries the prose the model spoke before the call. + public static InterruptionFixture StartWithProseBesideTool(string reply) + { + ProseBesideToolChatClient client = new(reply); + ConversationSession session = CreateSession(ToolYaml, client, new StubToolBuilder(/*lang=json,strict*/ """{ "price": 50 }""").Create); + return new InterruptionFixture(session, client, client.OpenGate, TestContext.Current.CancellationToken); + } + + /// Builds a session whose extractor model never answers, so the deadline of §8.7 must act. + public static InterruptionFixture StartWithHangingExtractor(string reply) + { + ScriptedChatClient replyClient = new(reply.Split(' ')); + HangingChatClient extractorClient = new(); + RoutingChatClientFactory chatClients = new RoutingChatClientFactory(replyClient).Route("fill", extractorClient); + + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(ExtractorYaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(chatClients))["main"]; + ConversationSessionFactory factory = new( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, chatClients)); + + return new InterruptionFixture( + factory.Create(), + [replyClient, extractorClient], + TestContext.Current.CancellationToken); + } + + /// Runs one turn directly on the session, the way a host that never streams drives it. + public Task RunTurnAsync(string userInput) + { + return Session.RunTurnAsync(userInput, _cancellation.Token); + } + + /// Gets the extraction failure the last finished turn recorded, or . + public string? LastExtractionFailure => Session.LastTurn?.ExtractionFailure; + + /// Waits for the first update, interrupts with what the caller heard, and returns the finished turn. + public async Task InterruptAfterFirstUpdateAsync(string heard) + { + await _firstUpdate.Task.WaitAsync(_cancellation.Token).ConfigureAwait(false); + + // Section 7.1: the relay reports both the heard text and the played duration together. + _ = Session.Cut(0, new TurnCut(heard, TimeSpan.FromMilliseconds(1820))); + _openGate(); + + await _run.WaitAsync(_cancellation.Token).ConfigureAwait(false); + Assert.NotNull(Session.LastTurn); + return Session.LastTurn; + } + + public async ValueTask DisposeAsync() + { + // A fact that stops before it interrupts must not leave the background task hanging. + _openGate(); + _ = _firstUpdate.TrySetResult(); + + try + { + await _run.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The host token already ended the run. Nothing here is left to observe. + } + + _cancellation.Dispose(); + foreach (IDisposable disposable in _disposables) + { + disposable.Dispose(); + } + } + + private async Task PumpAsync() + { + await foreach (ChatResponseUpdate? update in Session.RunTurnStreamingAsync("hi", _cancellation.Token) + .ConfigureAwait(false)) + { + // The tool-call and tool-result updates carry no TextContent, so only the reply + // itself opens the window in which a test may interrupt. + if (update.Contents.OfType().Any(text => text.Text.Length > 0)) + { + _ = _firstUpdate.TrySetResult(); + } + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs new file mode 100644 index 00000000..d516b7f9 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/InterruptionSessions.cs @@ -0,0 +1,107 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// The YAML documents and the session factory every interruption fact builds a + /// from. + /// + internal static class InterruptionSessions + { + internal const string NoToolYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "I answer everything" } + entries: + main: + agent: only + """; + + internal const string ToolYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + items: + - { id: only, instructions: "quote the price", tools: [ price_lookup ] } + entries: + main: + agent: only + """; + + internal const string ParallelToolYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: quote, kind: builtin, uses: orders.read, description: "Get a price quote for an item." } + agents: + items: + - { id: only, instructions: "quote both items", tools: [ quote ] } + entries: + main: + agent: only + """; + + internal const string ExtractorYaml = + """ + apiVersion: agentcore/v1 + state: + callerName: + type: string + writer: extractor + description: the name the caller gave + extractor: + model: { ref: fill } + when: after_reply + agents: + items: + - { id: only, instructions: "greet the caller" } + entries: + main: + agent: only + """; + + internal static ConversationSession CreateSession( + string yaml, + IChatClient reply, + Func? tools = null, + IAuditSinkPort? auditSink = null) + { + // ConversationObservers.Standard takes a required sink, because the composition root resolves + // providers.audit for every host and falls back to the in-process memory kind. An optional + // parameter has to be a compile-time constant, so the default is spelled here instead — a fact + // that does not care where its events land gets a fresh in-memory sink. + IAuditSinkPort sink = auditSink ?? new InMemoryAuditSink(); + + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + FakeChatClientFactory chatClients = new(reply); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) + { + Tools = TestToolRegistry.From(document, tools, TestContext.Current.CancellationToken), + })["main"]; + + ConversationSessionFactory factory = new( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, chatClients), + observers: ConversationObservers.Standard(sink, logger: null)); + + return factory.Create(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/ParallelToolCallChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/ParallelToolCallChatClient.cs new file mode 100644 index 00000000..ba1173da --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/ParallelToolCallChatClient.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Answers with one call, then, once that call's result is in context, answers with that same + /// call again alongside a brand new one, both on a single message. + /// + internal sealed class ParallelToolCallChatClient : IChatClient + { + /// The id of the conversation whose result arrives before the barge-in. + public const string FirstCallId = "conversation_a"; + + /// The id of the conversation still in flight when the barge-in arrives. + public const string SecondCallId = "conversation_b"; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + await Task.Yield(); + + HashSet answered = messages + .SelectMany(message => message.Contents.OfType()) + .Select(result => result.CallId) + .ToHashSet(StringComparer.Ordinal); + + AIFunction tool = options?.Tools?.OfType().FirstOrDefault() + ?? throw new InvalidOperationException("The turn offers no tool to call."); + string responseId = Guid.NewGuid().ToString("N"); + + if (!answered.Contains(FirstCallId)) + { + // Round one. One call, so the runtime's batch commits alone and finishes at once. + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent( + FirstCallId, tool.Name, new Dictionary(StringComparer.Ordinal) { ["index"] = 1 }), + ]) + { + ResponseId = responseId, + MessageId = responseId, + }; + + yield break; + } + + // Round two. The already-answered conversation rides again beside the new one, on one message. + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent( + FirstCallId, tool.Name, new Dictionary(StringComparer.Ordinal) { ["index"] = 1 }), + new FunctionCallContent( + SecondCallId, tool.Name, new Dictionary(StringComparer.Ordinal) { ["index"] = 2 }), + ]) + { + ResponseId = responseId, + MessageId = responseId, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs new file mode 100644 index 00000000..55a43c72 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/PartiallyAnsweredToolFactory.cs @@ -0,0 +1,35 @@ +using AgentCore.Application.Configuration.Schema; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Answers the first call to the one declared tool at once, and blocks the second call until the + /// run's own cancellation ends it. + /// + internal sealed class PartiallyAnsweredToolFactory + { + /// Signals once the second call is in flight and blocked. + public TaskCompletionSource SecondConversationStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public AIFunction? Create(ToolConfiguration tool) + { + ArgumentNullException.ThrowIfNull(tool); + return AIFunctionFactory.Create(AnswerAsync, tool.Id, tool.Description ?? tool.Id); + } + + private async Task AnswerAsync(int index, CancellationToken cancellationToken) + { + if (index == 1) + { + return "50"; + } + + _ = SecondConversationStarted.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + + // Unreachable: the delay above only ever ends in cancellation. + return string.Empty; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/ProseBesideToolChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/ProseBesideToolChatClient.cs new file mode 100644 index 00000000..28e70888 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/ProseBesideToolChatClient.cs @@ -0,0 +1,97 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Announces the tool in prose and calls it, both on one assistant message, then streams its + /// final reply and blocks after the first fragment until the test opens the gate. + /// + internal sealed class ProseBesideToolChatClient(string reply) : IChatClient + { + /// The line the model speaks before it calls the tool. + public const string Prose = "Let me check that for you"; + + private const string ToolCallId = "conversation_1"; + + private readonly string[] _fragments = reply.Split(' '); + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Lets every fragment after the first flow. + public void OpenGate() + { + _ = _gate.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + await Task.Yield(); + + bool answered = messages.Any(message => message.Contents.OfType().Any()); + string responseId = Guid.NewGuid().ToString("N"); + + if (!answered && options?.Tools?.OfType().FirstOrDefault() is { } tool) + { + // The prose and the conversation ride one message, which is what a real model produces. + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new TextContent(Prose), + new FunctionCallContent(ToolCallId, tool.Name, new Dictionary(StringComparer.Ordinal)), + ]) + { + ResponseId = responseId, + MessageId = responseId, + }; + + yield break; + } + + for (int index = 0; index < _fragments.Length; index++) + { + if (index == 1) + { + // The tool round already finished, so this is the round a barge-in interrupts. + await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + string text = index == 0 ? _fragments[index] : " " + _fragments[index]; + yield return new ChatResponseUpdate(ChatRole.Assistant, text) + { + ResponseId = responseId, + MessageId = responseId, + }; + } + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/ProseThenFailingToolChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/ProseThenFailingToolChatClient.cs new file mode 100644 index 00000000..ab20c321 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/ProseThenFailingToolChatClient.cs @@ -0,0 +1,63 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Speaks one line and calls the tool on the same message, every round, so a tool that keeps failing spends + /// its whole retry budget after the model already spoke. Round n says "Checking n. ". + /// + internal sealed class ProseThenFailingToolChatClient : IChatClient + { + private int _calls; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + int round = Interlocked.Increment(ref _calls); + string id = Guid.NewGuid().ToString("N"); + + if (options?.Tools?.OfType().FirstOrDefault() is not { } tool) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "{}") { ResponseId = id, MessageId = id }; + yield break; + } + + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new TextContent($"Checking {round}. "), + new FunctionCallContent($"call_{round}", tool.Name, new Dictionary(StringComparer.Ordinal)), + ]) + { + ResponseId = id, + MessageId = id, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/Interruption/SecondTurnGatedChatClient.cs b/tests/AgentCore.Application.Tests/Runtime/Interruption/SecondTurnGatedChatClient.cs new file mode 100644 index 00000000..ac22c28d --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/Interruption/SecondTurnGatedChatClient.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Streams its first reply to the end, then blocks the second one before it says a word. + /// + internal sealed class SecondTurnGatedChatClient(string first, string second) : IChatClient + { + private readonly string _first = first; + private readonly string _second = second; + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _calls; + + /// Signals once the second turn's own model call is in flight and blocked. + public TaskCompletionSource SecondTurnStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Lets the second reply flow. + public void OpenGate() + { + _ = _gate.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + await Task.Yield(); + + int index = Interlocked.Increment(ref _calls); + string responseId = Guid.NewGuid().ToString("N"); + + if (index > 1) + { + _ = SecondTurnStarted.TrySetResult(); + + // Not WaitAsync(cancellationToken): a defect that cancelled this turn would then + // look like the pass this test is trying to disprove. + await _gate.Task.ConfigureAwait(false); + } + + foreach (string? word in (index == 1 ? _first : _second).Split(' ') + .Select((word, position) => position == 0 ? word : " " + word)) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, word) + { + ResponseId = responseId, + MessageId = responseId, + }; + } + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs b/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs new file mode 100644 index 00000000..32fa378e --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/RawEventAgent.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// An agent that yields one update whose is a raw workflow event, for testing in isolation. + internal sealed class RawEventAgent(WorkflowEvent raw) : AIAgent + { + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, [new ErrorContent("An error occurred while executing the workflow.")]) { RawRepresentation = raw }; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs b/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs new file mode 100644 index 00000000..4c89df4a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/RecordingTurnCompleter.cs @@ -0,0 +1,66 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Stands in for the conversation's completer, as the design probes' ProtoCommitAgent did: its state is + /// stage-after: plus the reply, so a test can tell the state after the stage advance from any other. + /// It logs staged when the framework's hook had staged the run by the time it ran, then complete. + /// + internal sealed class RecordingTurnCompleter( + AgentCoreChatHistoryProvider history, AgentSession historySession, List log) : ITurnCompleter + { + /// Gets what the hook had staged when the completer ran. + public IReadOnlyList StagedAtComplete { get; private set; } = []; + + /// Gets the ids the commit reported. + public (string UserMessageId, string? ReplyMessageId)? Ids { get; private set; } + + /// Gets the turns an edit withdrew, or . + public WithdrawnTurns? Withdrawn { get; private set; } + + public void Superseded(WithdrawnTurns withdrawn) + { + Withdrawn = withdrawn; + } + + public ValueTask CompleteAsync(TurnCommit sealing, Exception? fault) + { + StagedAtComplete = history.Staged(historySession); + if (StagedAtComplete.Count > 0) + { + log.Add("staged"); + } + + log.Add("complete"); + + string reply = fault is not null ? "fallback" + : (sealing.Cut?.ShownText ?? ReplyText.From(sealing.Seen?.Messages ?? [])).Trim(); + return ValueTask.FromResult(sealing with + { + Completed = sealing.Cut is null && sealing.Disposition is null && fault is null, + Reply = reply, + State = new ConversationSessionState { Stage = "stage-after:" + reply }, + }); + } + + public async ValueTask RefusedAsync(Task refused) + { + return await refused; + } + + public void Committed((string UserMessageId, string? ReplyMessageId)? ids, string spoken, TurnCut? late, TurnCut? recut) + { + Ids = ids; + } + + public ValueTask FinishAsync() + { + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/ScriptedRunAgent.cs b/tests/AgentCore.Application.Tests/Runtime/ScriptedRunAgent.cs new file mode 100644 index 00000000..fdfafceb --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/ScriptedRunAgent.cs @@ -0,0 +1,66 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using AgentCore.Application.Tests.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// An agent with no layers of its own: it streams its fragments as assistant text, then throws when it holds a + /// fault. It records whether its stream reached its finally. + /// + internal sealed class ScriptedRunAgent(IReadOnlyList fragments, Exception? fault = null) : AIAgent + { + /// Gets whether the stream's finally ran, which it does only when the stream ends or is disposed. + public bool StreamClosed { get; private set; } + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + return new(new StubSession()); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + try + { + foreach (string fragment in fragments) + { + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, fragment) { MessageId = "m0" }; + } + + if (fault is not null) + { + throw fault; + } + } + finally + { + StreamClosed = true; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs b/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs new file mode 100644 index 00000000..e6d1296f --- /dev/null +++ b/tests/AgentCore.Application.Tests/Runtime/TurnFaultTelemetryTests.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using AgentCore.Application.Diagnostics; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Diagnostics; +using Xunit; +using static AgentCore.Application.Tests.Runtime.ConversationSessionTestSupport; + +namespace AgentCore.Application.Tests.Runtime +{ + /// + /// Owner ruling on a row-six fault (docs/probes/r2/audit/AUDIT.md, finding F, PII table): the exception + /// TYPE goes on the agentcore.turn span, and the exception OBJECT -- message and stack trace both + /// -- goes to the Error log. Neither carries the other's cut. + /// + public sealed class TurnFaultTelemetryTests + { + [Fact] + public async Task AToolFaultsMarkerMessage_NeverReachesTheSpan_AndAlwaysReachesTheLogsException() + { + RecordingLogger logger = new(); + List spans = []; + using ActivityListener listener = ListenToAgentCoreTelemetry(spans); + + using LoopingToolCallingChatClient reply = new(); + ThrowingToolBuilder tools = new(); + ConversationSession session = Build(ToolYaml, reply, null, tools.Create, logger: logger) + .Create("conversation-" + Guid.NewGuid().ToString("N")); + + _ = await session.RunTurnAsync("where is my order", TestContext.Current.CancellationToken); + + // ThrowingToolBuilder.Message is the marker here: a fixed literal nothing else in this run + // produces, thrown inside a real TimeoutException by the tool itself. + List snapshot; + lock (spans) + { + snapshot = [.. spans]; + } + + Activity span = Assert.Single(snapshot, item => string.Equals( + item.GetTagItem("gen_ai.conversation.id") as string, session.ConversationId, StringComparison.Ordinal)); + + Assert.DoesNotContain( + span.TagObjects, tag => tag.Value is string text && text.Contains(ThrowingToolBuilder.Message, StringComparison.Ordinal)); + Assert.Empty(span.Events); + Assert.NotNull(span.StatusDescription); + Assert.DoesNotContain(ThrowingToolBuilder.Message, span.StatusDescription, StringComparison.Ordinal); + + // The type, and only the type, stands in for it on the span. + Assert.Equal(typeof(TimeoutException).FullName, span.StatusDescription); + Assert.Equal(typeof(TimeoutException).FullName, span.GetTagItem("error.type")); + + LogLine line = Assert.Single(logger.Of(2)); + Assert.DoesNotContain(ThrowingToolBuilder.Message, line.Message, StringComparison.Ordinal); + Assert.NotNull(line.Exception); + Assert.IsType(line.Exception); + Assert.Contains(ThrowingToolBuilder.Message, line.Exception!.Message, StringComparison.Ordinal); + } + + /// Subscribes to the one activity source of this library, and no other. + private static ActivityListener ListenToAgentCoreTelemetry(List spans) + { + ActivityListener listener = new() + { + ShouldListenTo = source => + string.Equals(source.Name, AgentCoreTelemetry.ActivitySourceName, StringComparison.Ordinal), + Sample = (ref _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + lock (spans) + { + spans.Add(activity); + } + }, + }; + + ActivitySource.AddActivityListener(listener); + return listener; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs b/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs index 104b89fa..1bf5e599 100644 --- a/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs +++ b/tests/AgentCore.Application.Tests/Runtime/TurnPipelineTests.cs @@ -22,10 +22,9 @@ namespace AgentCore.Application.Tests.Runtime /// /// /// - /// R1, R2 and R3 used to be branches of the turn loop. They are two chat pipeline layers now: - /// ModerationChatClient refuses a flagged turn before the model runs, and - /// FallbackChatClient answers a run that threw or produced no text. Both report what they did - /// on a TurnDisposition, and the turn loop reads it to raise the rows it always raised. + /// R1, R2 and R3 are two pipeline layers: ModerationAgent refuses a flagged turn before the model + /// runs, and FallbackAgent answers a run that threw or produced no text. Both report what they did + /// on a TurnDisposition, and the turn loop reads it to raise the turn's rows. /// /// Every test here runs offline. There is no network conversation and no API key in this file. /// @@ -200,7 +199,7 @@ public async Task Run_FlaggedOutcome_RaisesSameAuditRowsAsBefore() } [Fact] - public async Task Run_ThrownOutcome_RaisesSameAuditRowsAsBefore() + public async Task Run_ThrownOutcome_WritesNoToolFailedRow() { // Arrange. InMemoryAuditSink sink = new(); @@ -208,15 +207,14 @@ public async Task Run_ThrownOutcome_RaisesSameAuditRowsAsBefore() ConversationSession session = Build(PlainYaml, model, sink: sink).Create("conversation-1"); // Act. - _ = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); + TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); - // Assert. The turn-altitude tool.failed row still names the fault, and still precedes the - // turn.completed of the same turn. + // Assert. The model threw, not a tool, so the turn fails as a run fault and names no tool. IReadOnlyList events = sink.EventsOf("conversation-1"); Assert.Equal( - [AuditEventKind.ConversationStarted, AuditEventKind.ToolFailed, AuditEventKind.TurnCompleted], + [AuditEventKind.ConversationStarted, AuditEventKind.TurnCompleted], events.Select(entry => entry.Kind)); - Assert.Contains("the vendor is down", events[1].Payload[AuditPayloadKeys.ToolError], StringComparison.Ordinal); + Assert.Equal("the turn's run faulted, so it spoke the fallback. the vendor is down", turn.Failure); } // ------------------------------------------------------------------------------------------- diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs new file mode 100644 index 00000000..abd4ffa8 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionBackgroundIdleTests.cs @@ -0,0 +1,323 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Harness; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// Issue #31: a running background: child counts as activity for the held-session idle timer, the + /// same way a running turn does (). + /// +#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0. + public sealed class ConversationSessionBackgroundIdleTests + { + private static readonly TimeSpan IdleTimeout = InMemoryConversationSessions.DefaultIdleTimeout; + + private const string LingeringChildYaml = + """ + apiVersion: agentcore/v1 + guards: + never: { "<": [ { var: turnIndex }, 0 ] } + agents: + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: parent, to: [ { stage: done, when: never } ] } + - { id: done, agent: blocker, terminal: true } + """; + + [Fact] + public async Task ABackgroundChildStillRunningAtTheIdleTimeoutKeepsTheSessionHeld() + { + FakeTimeProvider clock = Clock(); + GatedChatClient child = new(new ScriptedChatClient("done")); + child.Arm(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(child)), IdleTimeout, clock); + + ConversationSession session = + await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + _ = await session.RunTurnAsync("go", Token); + await child.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + // The turn itself is over; only the child it started is still going. + Assert.Null(session.Cuts.RunningTurn()); + + clock.Advance(IdleTimeout); + + Assert.Equal(1, sessions.Count); + Assert.False(IsDisposed(session)); + + _ = child.Open.TrySetResult(); + } + + [Fact] + public async Task ABackgroundChildStillRunningAtTheIdleTimeoutStampsTheWorkspace() + { + // The workspace is stamped wherever the idle poll treats a running child as activity, not only at + // turn start, so another server's boot sweep does not mistake this folder for dead while a + // background child, not a turn, is the only thing still using it. + string root = Directory.CreateTempSubdirectory("agentcore-ws-bg-").FullName; + try + { + FakeTimeProvider clock = Clock(); + GatedChatClient child = new(new ScriptedChatClient("done")); + child.Arm(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(child, workspaceRoot: root, timeProvider: clock)), IdleTimeout, clock); + + ConversationSession session = + await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + _ = await session.RunTurnAsync("go", Token); + await child.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + Assert.Null(session.Cuts.RunningTurn()); + DateTime stampAfterTurn = Directory.GetLastWriteTimeUtc(session.Workspace!); + + clock.Advance(IdleTimeout); + + Assert.Equal(clock.GetUtcNow().UtcDateTime, Directory.GetLastWriteTimeUtc(session.Workspace!)); + Assert.NotEqual(stampAfterTurn, Directory.GetLastWriteTimeUtc(session.Workspace!)); + + _ = child.Open.TrySetResult(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task ABackgroundChildStillRunningWellBeforeTheIdleTimeoutStampsTheWorkspaceAtEachPoll() + { + // The poll this test checks lands well inside the idle timeout, not at the moment the timeout is + // crossed: the whole background-poll window stamps the workspace, not only its last tick. + string root = Directory.CreateTempSubdirectory("agentcore-ws-bg-early-").FullName; + try + { + FakeTimeProvider clock = Clock(); + GatedChatClient child = new(new ScriptedChatClient("done")); + child.Arm(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(child, workspaceRoot: root, timeProvider: clock)), IdleTimeout, clock); + + ConversationSession session = + await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + _ = await session.RunTurnAsync("go", Token); + await child.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + DateTime stampAfterTurn = Directory.GetLastWriteTimeUtc(session.Workspace!); + + clock.Advance(HeldSession.BackgroundChildPollInterval); + + Assert.Equal(clock.GetUtcNow().UtcDateTime, Directory.GetLastWriteTimeUtc(session.Workspace!)); + Assert.NotEqual(stampAfterTurn, Directory.GetLastWriteTimeUtc(session.Workspace!)); + + _ = child.Open.TrySetResult(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task TheSessionUnloadsWithinOnePollAfterTheIdleTimeoutOnceTheChildEnds() + { + FakeTimeProvider clock = Clock(); + GatedChatClient child = new(new ScriptedChatClient("done")); + child.Arm(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(child)), IdleTimeout, clock); + + ConversationSession session = + await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + _ = await session.RunTurnAsync("go", Token); + await child.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + clock.Advance(IdleTimeout); + Assert.Equal(1, sessions.Count); + + // The child is read as running right up to this check, so this is where the fresh idle window + // starts from, not the later poll that first notices it is gone. + _ = child.Open.TrySetResult(); + + // The provider's own bookkeeping settles on a real thread; give it real time to catch up before + // the fake clock drives the next poll, or the poll below could still see the child as running. + await EventuallyAsync(() => !session.Lifetime.HasRunningBackgroundChild()); + + // Comfortably inside the fresh idle timeout: held. + clock.Advance(IdleTimeout - HeldSession.BackgroundChildPollInterval); + Assert.Equal(1, sessions.Count); + Assert.False(IsDisposed(session)); + + // Comfortably past idleTimeout + one poll interval since that point: unloaded, within the bound + // BackgroundChildPollInterval's doc comment states. + clock.Advance(TimeSpan.FromMinutes(1) + HeldSession.BackgroundChildPollInterval); + Assert.Equal(0, sessions.Count); + await EventuallyAsync(() => IsDisposed(session)); + } + + [Fact] + public async Task AChildThatEndsWellBeforeTheIdleTimeoutStillCountsAsActivity() + { + // Issue #31 fix round, defect 2: the child is released long before the idle timeout comes due at + // all. A check only at the deadline would find it already gone and expire right there, on schedule, + // as if the child had never run. The document is polled through the whole window instead, so the + // child's end (not the original deadline) is what the idle timeout counts from. + FakeTimeProvider clock = Clock(); + GatedChatClient child = new(new ScriptedChatClient("done")); + child.Arm(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(child)), IdleTimeout, clock); + + ConversationSession session = + await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + // An explicit touch before anything else happens. It must re-arm at the shorter background poll + // cadence, not at a plain idle timeout, or the poll below would never fire and this test would pass + // for the wrong reason. + Assert.NotNull(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + + _ = await session.RunTurnAsync("go", Token); + await child.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + TimeSpan childReleasedAt = TimeSpan.FromMinutes(2); + clock.Advance(childReleasedAt); + + _ = child.Open.TrySetResult(); + await EventuallyAsync(() => !session.Lifetime.HasRunningBackgroundChild()); + + // One poll tick notices the child is gone. Fake time is now childReleasedAt + one poll interval. + clock.Advance(HeldSession.BackgroundChildPollInterval); + + // The moment the original idle timeout, counted from session open rather than from the child's end, + // would have expired the session under the old bug. It must still be held. + TimeSpan sinceOpen = childReleasedAt + HeldSession.BackgroundChildPollInterval; + clock.Advance(IdleTimeout - sinceOpen); + Assert.Equal(1, sessions.Count); + Assert.False(IsDisposed(session)); + + // It does unload once its own idle timeout, counted from the child's end, is comfortably up. + clock.Advance(IdleTimeout); + Assert.Equal(0, sessions.Count); + await EventuallyAsync(() => IsDisposed(session)); + } + + [Fact] + public async Task TheBackgroundPollIntervalIsClampedToTheIdleTimeout() + { + // Issue #31 fix round, defect 4: an idle timeout shorter than the poll interval must not let the + // session outlive its own timeout just because nothing polled it in time. + FakeTimeProvider clock = Clock(); + TimeSpan shortIdleTimeout = TimeSpan.FromSeconds(5); + Assert.True(shortIdleTimeout < HeldSession.BackgroundChildPollInterval); + + GatedChatClient child = new(new ScriptedChatClient("done")); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(BuildFactory(child)), shortIdleTimeout, clock); + + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + clock.Advance(shortIdleTimeout); + + Assert.Equal(0, sessions.Count); + } + + [Fact] + public async Task AFaultReadingWhetherAChildIsRunningIsTreatedAsOneStillRunningAndLogged() + { + // Issue #31 fix round, defect 1: GetIncompleteTasks can throw. A state bag entry set through a live + // SetValue call always short-circuits to a CLR type check (proven while chasing this down: a + // mismatched cached type returns false, never touching JSON), so the only path that reaches a real + // JsonException is a session resumed from stored JSON, where a key comes back shaped for a + // different provider version. Thrown on the idle timer thread with no catch, this either crashes the + // process (TimeProvider.System) or leaves the timer never re-armed (a fake clock): reviewer-proven. + RecordingLoggerFactory logs = new(); + ConversationSessionFactory factory = + BuildFactory(new GatedChatClient(new ScriptedChatClient("done")), logs.CreateLogger("session")); + await using ConversationSession session = factory.Create("conversation-1"); + + BackgroundAgentsProvider provider = Assert.Single(session.Compiled.BackgroundProviders); + + Dictionary poisoned = new(StringComparer.Ordinal); + foreach (string key in provider.StateKeys) + { + poisoned[key] = JsonSerializer.SerializeToElement("not the object the provider expects"); + } + + session.AgentSession = await session.Compiled.TurnAgent.DeserializeSessionAsync( + HarnessSessionState.Wrap(poisoned), cancellationToken: Token); + + bool result = session.Lifetime.HasRunningBackgroundChild(); + + Assert.True(result); + CapturedLine line = Assert.Single(logs.Of(45)); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.NotNull(line.Exception); + } + + private static ConversationSessionFactory BuildFactory( + IChatClient child, ILogger? logger = null, string? workspaceRoot = null, TimeProvider? timeProvider = null) + { + ToolCallingChatClient parent = new( + "started", + new Dictionary(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }); + + RoutingChatClientFactory chatClients = new(parent); + _ = chatClients.Route("parent", parent); + _ = chatClients.Route("blocker", child); + + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(LingeringChildYaml), + new AgentCompilationContext(chatClients) { WorkspaceRoot = workspaceRoot })[SingleEntrySessionFactories.MainEntry]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + logger: logger, + timeProvider: timeProvider, + workspaceRoot: workspaceRoot); + } + + /// Reads whether the session was disposed: a disposed session refuses the turn slot. + private static bool IsDisposed(ConversationSession session) + { + try + { + if (session.Cuts.TryEnterTurn()) + { + session.Cuts.ReleaseTurn(); + } + + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } + } +#pragma warning restore MAAI001 +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs new file mode 100644 index 00000000..f1712cd6 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionCrossEntryTests.cs @@ -0,0 +1,297 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime.Harness; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// A request from one entry on a conversation another entry holds live is refused: the other entry's + /// session keeps running untouched, and this entry may open the id only once that session is gone. + /// + public sealed class ConversationSessionCrossEntryTests : IDisposable + { + private const string PhoneEntry = "phone"; + + private const string ChatEntry = "chat"; + + // Pinned by ConversationSessionShellTests.FirstRequest_ListsATool_NamedRunShell. + private const string RunShellToolName = "run_shell"; + + private const string PhoneYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "phone agent" } + entries: + phone: + agent: only + """; + + private const string ShellPhoneYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "run commands", shell: { kind: local, policy: { deny: ["^rm "] } } } + entries: + phone: + agent: only + """; + + private const string ChatYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "chat agent" } + entries: + chat: + agent: only + """; + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "agentcore-cross-entry-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task AnEntryOpeningAnIdAnotherEntryHoldsIsRefusedAndThatEntrysSessionIsUntouched() + { + ShellScriptedClient phoneClient = new(RunShellToolName, "echo $$"); + InMemoryConversationStore store = new(); + + ConversationSessionFactory phoneFactory = BuildFactory(ShellPhoneYaml, PhoneEntry, phoneClient, store, _root); + ConversationSessionFactory chatFactory = BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply"), store, _root); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + ConversationSession phone = await sessions.GetOrOpenAsync(PhoneEntry, "conversation-1", null, Token); + _ = await phone.RunTurnAsync("go", Token); + + int pid = ProcHelpers.ParsePid(phoneClient.ToolResults[0]); + Assert.True(Directory.Exists($"/proc/{pid}"), "the shell should be alive before the refusal."); + + // A file phone's folder holds. A refusal that touched the folder would leave this gone. + string workspace = phone.Workspace!; + string sentinel = Path.Combine(workspace, "sentinel.txt"); + await File.WriteAllTextAsync(sentinel, "still here?", Token); + + ConversationInUseException refused = await Assert.ThrowsAsync( + () => sessions.GetOrOpenAsync(ChatEntry, "conversation-1", null, Token).AsTask()); + Assert.Equal("conversation-1", refused.ConversationId); + Assert.Equal(PhoneEntry, refused.HoldingEntry); + + Assert.True(Directory.Exists($"/proc/{pid}"), "a refused open must not touch phone's shell."); + Assert.True(File.Exists(sentinel), "a refused open must not touch phone's workspace folder."); + Assert.Same(phone, await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); + Assert.Equal(1, sessions.Count); + } + + [Fact] + public async Task AfterThatEntrysSessionIsClosedAnotherEntryOpensTheSameIdAndSeesItsHistory() + { + RequestCapturingChatClient chatReply = new(new ScriptedChatClient("chat reply")); + InMemoryConversationStore store = new(); + + ConversationSessionFactory phoneFactory = BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply"), store); + ConversationSessionFactory chatFactory = BuildFactory(ChatYaml, ChatEntry, chatReply, store); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + ConversationSession phone = await sessions.GetOrOpenAsync(PhoneEntry, "conversation-1", null, Token); + _ = await phone.RunTurnAsync("go", Token); + + await sessions.CloseAsync(PhoneEntry, "conversation-1", Token); + Assert.Null(await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); + + ConversationSession chat = await sessions.GetOrOpenAsync(ChatEntry, "conversation-1", null, Token); + _ = await chat.RunTurnAsync("hi", Token); + + // chat's own first request carries the words phone's turn wrote to the shared store. + Assert.Contains(chatReply.Requests[0], message => message.Text == "go"); + } + + [Fact] + public async Task TwoEntriesWithDifferentIdsEachGetTheirOwnAgentsSession() + { + ScriptedChatClient phoneReply = new("phone reply"); + ScriptedChatClient chatReply = new("chat reply"); + ConversationSessionFactory phoneFactory = BuildFactory(PhoneYaml, PhoneEntry, phoneReply); + ConversationSessionFactory chatFactory = BuildFactory(ChatYaml, ChatEntry, chatReply); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + ConversationSession phone = await sessions.GetOrOpenAsync(PhoneEntry, "conversation-a", null, Token); + ConversationSession chat = await sessions.GetOrOpenAsync(ChatEntry, "conversation-b", null, Token); + + TurnResult phoneTurn = await phone.RunTurnAsync("hi", Token); + TurnResult chatTurn = await chat.RunTurnAsync("hi", Token); + + Assert.Equal("phone reply", phoneTurn.ReplyText); + Assert.Equal("chat reply", chatTurn.ReplyText); + Assert.Equal(2, sessions.Count); + } + + [Fact] + public async Task TryGetAsyncUnderAnotherEntryReturnsNullAndLeavesTheOtherEntrysSessionOpen() + { + ConversationSessionFactory phoneFactory = BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply")); + ConversationSessionFactory chatFactory = BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply")); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + ConversationSession phone = await sessions.GetOrOpenAsync(PhoneEntry, "conversation-1", null, Token); + + Assert.Null(await sessions.TryGetAsync(ChatEntry, "conversation-1", Token)); + Assert.Same(phone, await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); + Assert.Equal(1, sessions.Count); + } + + [Fact] + public async Task OpeningAnUndeclaredEntryThrowsArgumentException() + { + ConversationSessionFactory phoneFactory = BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply")); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory), TimeSpan.FromMinutes(30), Clock()); + + ArgumentException failure = await Assert.ThrowsAsync( + () => sessions.GetOrOpenAsync("missing", "conversation-1", null, Token).AsTask()); + Assert.Contains(PhoneEntry, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TwoEntriesRacingToOpenOneIdHoldTheFirstToReserveItAndTheRefusedOneBuildsNothing() + { + // Every session writes conversation.started as it is built, so a loser built and then thrown away + // would leave a start in the winner's audit chain that no conversation ever had. + RecordingConversationObserver observer = new(); + InMemoryConversationStore store = new(); + + using GatedSessionFactory phoneFactory = new( + BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply"), store, _root, observer)); + using GatedSessionFactory chatFactory = new( + BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply"), store, _root, observer)); + chatFactory.Release(); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + // phone reserves the id, then stalls while building its session; chat asks for the id meanwhile. + Task phoneOpen = OwnThread.Run( + () => sessions.GetOrOpenAsync(PhoneEntry, "conversation-1", null, Token).AsTask(), Token); + Assert.True(phoneFactory.Entered.Wait(TimeSpan.FromSeconds(30), Token), "phone never reached the build."); + + ConversationInUseException refused = await Assert.ThrowsAsync( + () => sessions.GetOrOpenAsync(ChatEntry, "conversation-1", null, Token).AsTask()); + Assert.Equal(PhoneEntry, refused.HoldingEntry); + + phoneFactory.Release(); + ConversationSession phone = await phoneOpen; + + Assert.Equal(0, chatFactory.Calls); + Assert.Single(observer.Kinds, kind => kind == ConversationEventKind.ConversationStarted); + Assert.Same(phone, await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); + Assert.Null(await sessions.TryGetAsync(ChatEntry, "conversation-1", Token)); + Assert.Equal(1, sessions.Count); + } + + [Fact] + public async Task ACloseFromAnEntryThatDoesNotHoldTheIdClosesNothing() + { + ShellScriptedClient phoneClient = new(RunShellToolName, "echo $$"); + InMemoryConversationStore store = new(); + + ConversationSessionFactory phoneFactory = BuildFactory(ShellPhoneYaml, PhoneEntry, phoneClient, store, _root); + ConversationSessionFactory chatFactory = BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply"), store, _root); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + ConversationSession phone = await sessions.GetOrOpenAsync(PhoneEntry, "conversation-1", null, Token); + _ = await phone.RunTurnAsync("go", Token); + int pid = ProcHelpers.ParsePid(phoneClient.ToolResults[0]); + string sentinel = Path.Combine(phone.Workspace!, "sentinel.txt"); + await File.WriteAllTextAsync(sentinel, "phone's file", Token); + + await sessions.CloseAsync(ChatEntry, "conversation-1", Token); + + Assert.Same(phone, await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); + Assert.True(Directory.Exists($"/proc/{pid}"), "chat's close must not touch phone's shell."); + Assert.True(File.Exists(sentinel), "chat's close must not touch phone's workspace folder."); + } + + [Fact] + public async Task AnOpenFromAnotherEntryDuringACloseWaitsForItThenOpens() + { + // A closing session's slot is still taken by its old entry. An open from a different entry must + // wait for the teardown rather than be refused as a cross-entry conflict. + ParkingConversationStore transcript = new(); + ConversationSessionFactory phoneFactory = BuildFactory(PhoneYaml, PhoneEntry, new ScriptedChatClient("phone reply"), transcript, _root); + ConversationSessionFactory chatFactory = BuildFactory(ChatYaml, ChatEntry, new ScriptedChatClient("chat reply"), transcript, _root); + using InMemoryConversationSessions sessions = new(Owner(phoneFactory, chatFactory), TimeSpan.FromMinutes(30), Clock()); + + ConversationSession phone = await sessions.GetOrOpenAsync(PhoneEntry, "conversation-1", null, Token); + Task turn = phone.RunTurnAsync("go", Token); + await transcript.Parked; + + Task closing = sessions.CloseAsync(PhoneEntry, "conversation-1", Token).AsTask(); + ValueTask opening = sessions.GetOrOpenAsync(ChatEntry, "conversation-1", null, Token); + + Assert.False(opening.IsCompleted, "an open from another entry during a close must wait for it, not be refused."); + Assert.Null(await sessions.TryGetAsync(ChatEntry, "conversation-1", Token)); + + transcript.Release(); + ConversationSession chat = await opening; + await closing; + _ = await turn; + + Assert.NotSame(phone, chat); + Assert.Same(chat, await sessions.TryGetAsync(ChatEntry, "conversation-1", Token)); + Assert.Null(await sessions.TryGetAsync(PhoneEntry, "conversation-1", Token)); + } + + private static ConversationSessionFactory BuildFactory( + string yaml, + string entry, + IChatClient reply, + IConversationStore? store = null, + string? workspaceRoot = null, + IConversationObserver? observer = null) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); + RoutingChatClientFactory chatClients = new(reply); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) { ConversationStore = store, WorkspaceRoot = workspaceRoot })[entry]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + observers: observer is null ? null : [observer], + workspaceRoot: workspaceRoot); + } + + private static Dictionary Owner( + IConversationSessionFactory phone, IConversationSessionFactory? chat = null) + { + Dictionary owner = new(StringComparer.Ordinal) { [PhoneEntry] = phone }; + if (chat is not null) + { + owner[ChatEntry] = chat; + } + + return owner; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs new file mode 100644 index 00000000..498e8d12 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionGetOrOpenTests.cs @@ -0,0 +1,229 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// One conversation id has at most one live session: concurrent opens share one build, a live session is + /// handed back as it is, and an open that meets a closing session waits for the whole teardown. + /// + public sealed class ConversationSessionGetOrOpenTests : IDisposable + { + private const string Main = SingleEntrySessionFactories.MainEntry; + + private static readonly TimeSpan IdleTimeout = InMemoryConversationSessions.DefaultIdleTimeout; + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "agentcore-get-or-open-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public async Task TwoConcurrentOpensOfANewIdBuildOneSession() + { + using GatedSessionFactory factory = new(Factory()); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), IdleTimeout, Clock()); + + Task first = OwnThread.Run( + () => sessions.GetOrOpenAsync(Main, "conversation-1", null, Token).AsTask(), Token); + Assert.True(factory.Entered.Wait(TimeSpan.FromSeconds(30), Token), "the first open never reached the build."); + + ValueTask second = sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + Assert.False(second.IsCompleted, "the second open must wait for the first one's build, not run its own."); + + factory.Release(); + + Assert.Same(await first, await second); + Assert.Equal(1, factory.Calls); + Assert.Equal(1, sessions.Count); + } + + [Fact] + public async Task ALiveSessionIsReturnedWithItsOwnStateNotTheOnePassedIn() + { + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, Clock()); + + ConversationSession opened = await sessions.GetOrOpenAsync( + Main, "conversation-1", new ConversationSessionState { NextTurnIndex = 7 }, Token); + ConversationSession again = await sessions.GetOrOpenAsync( + Main, "conversation-1", new ConversationSessionState { NextTurnIndex = 41 }, Token); + + Assert.Same(opened, again); + Assert.Equal(7, again.Snapshot().NextTurnIndex); + } + + [Fact] + public async Task AnOpenDuringACloseWaitsForTheTeardownThenReadsTheFlushedWordsAndKeepsItsOwnFile() + { + // The voice path: the caller hung up, and a new call on the same id arrives while the old session still + // owes the store its last words. The old close deletes / last, the same folder the new session uses. + ParkingConversationStore transcript = new(); + RequestCapturingChatClient reply = new(new ScriptedChatClient("hello")); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(Factory(transcript, reply, workspaceRoot: _root)), IdleTimeout, Clock()); + + ConversationSession old = await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + Task turn = old.RunTurnAsync("go", Token); + await transcript.Parked; + + Task closing = sessions.CloseAsync(Main, "conversation-1", Token).AsTask(); + ValueTask reopening = sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + + Assert.False(reopening.IsCompleted, "an open must wait until the old session's teardown is done."); + Assert.Null(await sessions.TryGetAsync(Main, "conversation-1", Token)); + + transcript.Release(); + ConversationSession fresh = await reopening; + await closing; + _ = await turn; + + Assert.NotSame(old, fresh); + string file = Path.Combine(fresh.Workspace!, "chart.txt"); + await File.WriteAllTextAsync(file, "the new session's file", Token); + Assert.True(File.Exists(file), "the old close must be done with the folder before the new session gets it."); + + _ = await fresh.RunTurnAsync("again", Token); + IReadOnlyList request = reply.Requests[^1]; + Assert.Contains(request, message => message.Text == "go"); + Assert.Contains(request, message => message.Role == ChatRole.Assistant && message.Text == "hello"); + } + + [Fact] + public async Task AnOpenDuringAnIdleUnloadWaitsForTheTeardownThenReadsTheFlushedWordsAndKeepsItsOwnFile() + { + // The rewrite is queued after the turn ended, so the idle timer unloads a session that still owes it. + ParkingRewriteStore transcript = new(); + RequestCapturingChatClient reply = new(new ScriptedChatClient("hello")); + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(Factory(transcript, reply, workspaceRoot: _root)), IdleTimeout, clock); + + ConversationSession old = await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + _ = await old.RunTurnAsync("go", Token); + Assert.True(old.Cut(0, new TurnCut("hel", Played: null))); + await transcript.Parked; + + clock.Advance(IdleTimeout); + ValueTask reopening = sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + + Assert.False(reopening.IsCompleted, "an open must wait until the unloaded session's teardown is done."); + Assert.Null(await sessions.TryGetAsync(Main, "conversation-1", Token)); + + transcript.Release(); + ConversationSession fresh = await reopening; + + Assert.NotSame(old, fresh); + string file = Path.Combine(fresh.Workspace!, "chart.txt"); + await File.WriteAllTextAsync(file, "the new session's file", Token); + Assert.True(File.Exists(file), "the unload must be done with the folder before the new session gets it."); + + _ = await fresh.RunTurnAsync("again", Token); + IReadOnlyList request = reply.Requests[^1]; + Assert.Contains(request, message => message.Role == ChatRole.Assistant && message.Text == "hel"); + Assert.DoesNotContain(request, message => message.Text == "hello"); + } + + [Fact] + public async Task ABuildThatThrowsFaultsEveryWaiterAndLeavesTheIdFreeForARetry() + { + InvalidOperationException fault = new("the build failed."); + using GatedSessionFactory factory = new(Factory()) { FirstBuildFault = fault }; + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), IdleTimeout, Clock()); + + Task first = OwnThread.Run( + () => sessions.GetOrOpenAsync(Main, "conversation-1", null, Token).AsTask(), Token); + Assert.True(factory.Entered.Wait(TimeSpan.FromSeconds(30), Token), "the first open never reached the build."); + ValueTask waiter = sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + Assert.False(waiter.IsCompleted, "the waiter must wait for the first build, not run its own."); + + factory.Release(); + + Assert.Same(fault, await Assert.ThrowsAsync(() => first)); + Assert.Same(fault, await Assert.ThrowsAsync(() => waiter.AsTask())); + Assert.Equal(0, sessions.Count); + + ConversationSession retried = await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + + Assert.Same(retried, await sessions.TryGetAsync(Main, "conversation-1", Token)); + Assert.Equal(2, factory.Calls); + } + + [Fact] + public async Task AWaiterThatCancelsLeavesTheBuildRunningForTheCallerThatStartedIt() + { + using GatedSessionFactory factory = new(Factory()); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), IdleTimeout, Clock()); + using CancellationTokenSource waiterCancel = CancellationTokenSource.CreateLinkedTokenSource(Token); + + Task first = OwnThread.Run( + () => sessions.GetOrOpenAsync(Main, "conversation-1", null, Token).AsTask(), Token); + Assert.True(factory.Entered.Wait(TimeSpan.FromSeconds(30), Token), "the first open never reached the build."); + ValueTask waiter = sessions.GetOrOpenAsync(Main, "conversation-1", null, waiterCancel.Token); + + await waiterCancel.CancelAsync(); + _ = await Assert.ThrowsAnyAsync(() => waiter.AsTask().WaitAsync(TimeSpan.FromSeconds(10), Token)); + + factory.Release(); + + Assert.Same(await first, await sessions.TryGetAsync(Main, "conversation-1", Token)); + Assert.Equal(1, factory.Calls); + } + + [Fact] + public async Task AWaiterThatCancelsDuringACloseLeavesTheCloseRunning() + { + ParkingConversationStore transcript = new(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(transcript)), IdleTimeout, Clock()); + using CancellationTokenSource waiterCancel = CancellationTokenSource.CreateLinkedTokenSource(Token); + + ConversationSession old = await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + Task turn = old.RunTurnAsync("go", Token); + await transcript.Parked; + Task closing = sessions.CloseAsync(Main, "conversation-1", Token).AsTask(); + ValueTask waiter = sessions.GetOrOpenAsync(Main, "conversation-1", null, waiterCancel.Token); + + await waiterCancel.CancelAsync(); + _ = await Assert.ThrowsAnyAsync(() => waiter.AsTask().WaitAsync(TimeSpan.FromSeconds(10), Token)); + + transcript.Release(); + await closing; + _ = await turn; + + Assert.True(transcript.Landed); + Assert.NotSame(old, await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token)); + } + + [Fact] + public async Task DisposingTheOwnerDuringACloseReleasesTheWaiterWhenTheCloseIsDone() + { + ParkingConversationStore transcript = new(); + InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(transcript)), IdleTimeout, Clock()); + + ConversationSession old = await sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + Task turn = old.RunTurnAsync("go", Token); + await transcript.Parked; + Task closing = sessions.CloseAsync(Main, "conversation-1", Token).AsTask(); + ValueTask waiter = sessions.GetOrOpenAsync(Main, "conversation-1", null, Token); + + sessions.Dispose(); + transcript.Release(); + + await closing; + _ = await turn; + _ = await Assert.ThrowsAsync(() => waiter.AsTask()); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs new file mode 100644 index 00000000..8e9494f1 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionIdleTimeoutTests.cs @@ -0,0 +1,255 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Runtime; +using AgentCore.TestSupport; +using AgentCore.Domain; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// A held session unloads itself once it has been untouched for the idle timeout, on its own timer. + /// + public sealed class ConversationSessionIdleTimeoutTests + { + private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task ASessionNobodyTouchesClosesItselfAtTheIdleTimeout() + { + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + clock.Advance(IdleTimeout - TimeSpan.FromSeconds(1)); + Assert.Equal(1, sessions.Count); + + // A caller that abandons a text conversation never reaches a terminal stage, so nothing else would + // ever drop this session and the process would hold it for its whole life. + clock.Advance(TimeSpan.FromSeconds(1)); + + Assert.Equal(0, sessions.Count); + Assert.Null(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + } + + [Fact] + public void TheDefaultIdleTimeoutIsTenMinutes() + { + Assert.Equal(TimeSpan.FromMinutes(10), InMemoryConversationSessions.DefaultIdleTimeout); + } + + [Fact] + public async Task AnExpiringSessionWritesNoConversationEndedEventAndTheSessionIsDisposed() + { + // An idle unload frees the session's resources without ending the conversation: no + // conversation.ended event, so the conversation stays open in the store for a caller to resume. + RecordingConversationObserver observer = new(); + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(observer: observer)), IdleTimeout, clock); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + clock.Advance(IdleTimeout); + + await EventuallyAsync(() => IsDisposed(session)); + Assert.DoesNotContain(ConversationEventKind.ConversationEnded, observer.Kinds); + } + + [Fact] + public async Task ReadingASessionPutsItsIdleClockBackToZero() + { + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + clock.Advance(TimeSpan.FromMinutes(20)); + Assert.NotNull(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + + // Forty minutes since it was opened, twenty since it was last read. A conversation still being had + // must not be dropped out from under the caller. + clock.Advance(TimeSpan.FromMinutes(20)); + Assert.Equal(1, sessions.Count); + + clock.Advance(TimeSpan.FromMinutes(10)); + Assert.Equal(0, sessions.Count); + } + + [Fact] + public async Task ASessionWhoseTurnIsRunningIsKeptAndClosesTheIdleTimeoutAfterTheTurnEnds() + { + // The turn is waiting on its own write. Expiring now would dispose the session under a turn + // whose words have not landed yet. + ParkingConversationStore transcript = new(); + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(transcript)), IdleTimeout, clock); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + Task turn = session.RunTurnAsync("hello", Token); + await transcript.Parked; + + clock.Advance(IdleTimeout); + clock.Advance(IdleTimeout); + Assert.Equal(1, sessions.Count); + Assert.False(IsDisposed(session)); + + transcript.Release(); + _ = await turn; + Assert.True(transcript.Landed); + await clock.WaitForTimersAsync(clock.GetUtcNow() + IdleTimeout, 1).WaitAsync(TimeSpan.FromSeconds(10), Token); + + clock.Advance(IdleTimeout - TimeSpan.FromSeconds(1)); + Assert.Equal(1, sessions.Count); + + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(0, sessions.Count); + await EventuallyAsync(() => IsDisposed(session)); + } + + [Fact] + public async Task ATurnRunningPastTheIdleTimeoutStampsTheWorkspaceOnceItCrossesTheTimeout() + { + // A turn alone (no background child) holds the session past its idle timeout. The moment the idle + // poll first finds the turn still running is stamped, so another server's boot sweep does not + // mistake this folder for one a crashed process left behind. + string root = Directory.CreateTempSubdirectory("agentcore-ws-turn-").FullName; + try + { + ParkingConversationStore transcript = new(); + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(Factory(transcript, workspaceRoot: root, timeProvider: clock)), IdleTimeout, clock); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + Task turn = session.RunTurnAsync("hello", Token); + await transcript.Parked; + + DateTime stampAtTurnStart = Directory.GetLastWriteTimeUtc(session.Workspace!); + + clock.Advance(IdleTimeout); + + Assert.Equal(1, sessions.Count); + Assert.Equal(clock.GetUtcNow().UtcDateTime, Directory.GetLastWriteTimeUtc(session.Workspace!)); + Assert.NotEqual(stampAtTurnStart, Directory.GetLastWriteTimeUtc(session.Workspace!)); + + transcript.Release(); + _ = await turn; + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task AStuckTurnInOneSessionDoesNotStopOtherSessionsFromExpiring() + { + // Review finding R5-2. Closing a session waits for its running turn, so a close that one session + // cannot finish must hold that session only. + FakeTimeProvider clock = Clock(); + using HangUntilCancelledChatClient model = new(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(reply: model)), IdleTimeout, clock); + + ConversationSession busy = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "a-busy", null, Token); + for (int i = 0; i < 20; i++) + { + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "idle-" + i, null, Token); + } + + using CancellationTokenSource turnCancel = new(); + Task turn = busy.RunTurnAsync("hello", turnCancel.Token); + await model.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + + clock.Advance(IdleTimeout + TimeSpan.FromMinutes(1)); + int held = sessions.Count; + + await turnCancel.CancelAsync(); + _ = await Record.ExceptionAsync(() => turn); + + Assert.Equal(1, held); + Assert.Same(busy, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "a-busy", Token)); + } + + [Fact] + public async Task ALookupThatTouchesTheSessionAfterItsTimerCameDueKeepsTheSession() + { + // The timer came due and its callback is on its way to a thread when the request arrives. The + // request is live, so the callback must see the touch and stand down. + FakeTimeProvider clock = Clock(); + DeferredTimeProvider deferred = new(clock); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, deferred); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + clock.Advance(IdleTimeout); + Assert.Same(session, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + Assert.Equal(1, deferred.RunDue()); + + Assert.Equal(1, sessions.Count); + Assert.False(IsDisposed(session)); + + clock.Advance(IdleTimeout); + Assert.Equal(1, deferred.RunDue()); + Assert.Equal(0, sessions.Count); + } + + [Fact] + public async Task DisposingTheStoreStopsEveryTimerAndLeavesTheSessionsHeld() + { + FakeTimeProvider clock = Clock(); + InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + sessions.Dispose(); + clock.Advance(IdleTimeout * 2); + + Assert.Same(session, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + Assert.False(IsDisposed(session)); + _ = await Assert.ThrowsAsync(() => sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-2", null, Token).AsTask()); + } + + [Fact] + public async Task DisposingTheStoreWhileABuildIsInFlightAlsoStopsTheBuiltSessionsTimer() + { + FakeTimeProvider clock = Clock(); + using GatedSessionFactory factory = new(Factory()); + InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), IdleTimeout, clock); + + Task opening = OwnThread.Run( + () => sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token).AsTask(), Token); + Assert.True(factory.Entered.Wait(TimeSpan.FromSeconds(30), Token), "the build never started."); + + sessions.Dispose(); + factory.Release(); + + ConversationSession session = await opening; + clock.Advance(IdleTimeout * 2); + + Assert.Same(session, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + Assert.False(IsDisposed(session)); + } + + [Fact] + public void AnIdleTimeoutLongerThanATimerCanWaitIsRefused() + { + _ = Assert.Throws( + () => new InMemoryConversationSessions(SingleEntrySessionFactories.Of(Factory()), TimeSpan.FromDays(50), Clock())); + } + + /// Reads whether the session was disposed: a disposed session refuses the turn slot. + private static bool IsDisposed(ConversationSession session) + { + try + { + if (session.Cuts.TryEnterTurn()) + { + session.Cuts.ReleaseTurn(); + } + + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs new file mode 100644 index 00000000..2323d667 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceHeartbeatTests.cs @@ -0,0 +1,74 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Tests.Transcript; +using AgentCore.TestSupport; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// The workspace heartbeat ('s re-arm at half the idle timeout while a turn runs + /// past it) across more than one long turn on the same session: each turn gets its own run of stamps, the + /// heartbeat resets cleanly between them, and the session still expires normally once both are done. + /// + public sealed class ConversationSessionWorkspaceHeartbeatTests : IDisposable + { + private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(10); + + private readonly string _root = Directory.CreateTempSubdirectory("agentcore-ws-heartbeat-").FullName; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private DateTime Stamp(string conversationId) + { + return Directory.GetLastWriteTimeUtc(Path.Combine(_root, conversationId)); + } + + [Fact] + public async Task HeartbeatKeepsStampingAcrossTwoLongTurnsInTurnThenTheSessionExpires() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + TaskCompletionSource[] gates = [new(), new()]; + TaskCompletionSource[] entered = [new(), new()]; + CapturingChatClient reply = new(async i => { entered[i].TrySetResult(); await gates[i].Task; }, "a", "b"); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(Factory(reply: reply, workspaceRoot: _root, timeProvider: clock)), IdleTimeout, clock); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "c1", null, Token); + + for (int t = 0; t < 2; t++) + { + Task turn = session.RunTurnAsync("q" + t, Token); + await entered[t].Task.WaitAsync(TimeSpan.FromSeconds(10), Token); + + clock.Advance(IdleTimeout); + Assert.Equal(clock.GetUtcNow().UtcDateTime, Stamp("c1")); + + // Six more half-idle-timeout ticks: three full idle timeouts of continuous heartbeat, well past + // a single re-arm, so this proves the timer keeps re-arming itself rather than firing once. + for (int tick = 0; tick < 6; tick++) + { + clock.Advance(IdleTimeout / 2); + Assert.Equal(clock.GetUtcNow().UtcDateTime, Stamp("c1")); + Assert.Equal(1, sessions.Count); + } + + gates[t].SetResult(); + await turn; + DateTime freedAt = clock.GetUtcNow().UtcDateTime; + await EventuallyAsync(() => Stamp("c1") == freedAt); + clock.Advance(TimeSpan.FromMinutes(1)); + } + + clock.Advance(IdleTimeout); + await EventuallyAsync(() => sessions.Count == 0); + await EventuallyAsync(() => !Directory.Exists(Path.Combine(_root, "c1"))); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs new file mode 100644 index 00000000..97171cdd --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionWorkspaceStampTests.cs @@ -0,0 +1,36 @@ +using AgentCore.Application.Runtime; +using AgentCore.TestSupport; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// A turn start stamps its conversation's workspace folder with the session's own clock, so the boot sweep + /// () can tell a folder still in use from one a crashed + /// process left behind. + /// + public sealed class ConversationSessionWorkspaceStampTests + { + [Fact] + public async Task RunningATurnStampsTheWorkspaceFoldersLastWriteTimeToTheSessionsClock() + { + string root = Directory.CreateTempSubdirectory("agentcore-ws-turnstart-").FullName; + try + { + FakeTimeProvider clock = Clock(); + ConversationSessionFactory factory = Factory(workspaceRoot: root, timeProvider: clock); + await using ConversationSession session = factory.Create("conversation-1"); + + clock.Advance(TimeSpan.FromMinutes(7)); + _ = await session.RunTurnAsync("hello", Token); + + Assert.Equal(clock.GetUtcNow().UtcDateTime, Directory.GetLastWriteTimeUtc(session.Workspace!)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs new file mode 100644 index 00000000..b93a3771 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsFixture.cs @@ -0,0 +1,78 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Sessions +{ + /// The one-agent session factory and the fixed clock the session store tests run on. + internal static class ConversationSessionsFixture + { + private const string Document = """ + apiVersion: agentcore/v1 + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + agent: only + """; + + internal static CancellationToken Token => TestContext.Current.CancellationToken; + + internal static FakeTimeProvider Clock() + { + return new(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); + } + + internal static ConversationSessionFactory Factory( + IConversationStore? transcript = null, + IChatClient? reply = null, + IConversationObserver? observer = null, + string? workspaceRoot = null, + TimeProvider? timeProvider = null) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(Document); + RoutingChatClientFactory chatClients = new(reply ?? new ScriptedChatClient("hello")); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(chatClients) { ConversationStore = transcript, WorkspaceRoot = workspaceRoot })[SingleEntrySessionFactories.MainEntry]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, chatClients), + timeProvider: timeProvider, + observers: observer is null ? null : [observer], + workspaceRoot: workspaceRoot); + } + + /// Waits for a condition that background work makes true. + /// The condition. + /// A task that completes once the condition holds. + /// The condition did not hold within ten seconds. + internal static async Task EventuallyAsync(Func condition) + { + using CancellationTokenSource bound = CancellationTokenSource.CreateLinkedTokenSource(Token); + bound.CancelAfter(TimeSpan.FromSeconds(10)); + + while (!condition()) + { + try + { + await Task.Delay(10, bound.Token); + } + catch (OperationCanceledException) when (!Token.IsCancellationRequested) + { + throw new TimeoutException("The condition did not hold within ten seconds."); + } + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs index c285e07d..d0b5f662 100644 --- a/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs +++ b/tests/AgentCore.Application.Tests/Sessions/ConversationSessionsTests.cs @@ -1,14 +1,9 @@ using AgentCore.TestSupport; using AgentCore.Application.Sessions.Memory; -using AgentCore.Application.Configuration.Compilation; -using AgentCore.Application.Configuration.Parsing; -using AgentCore.Application.Configuration.Validation; -using AgentCore.Application.Ports; using AgentCore.Application.Runtime; -using Microsoft.Extensions.AI; -using Xunit; using AgentCore.Domain; -using AgentCore.Application.Configuration.Schema; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; namespace AgentCore.Application.Tests.Sessions { @@ -17,206 +12,83 @@ namespace AgentCore.Application.Tests.Sessions /// public sealed class ConversationSessionsTests { - private static CancellationToken Token => TestContext.Current.CancellationToken; - [Fact] public async Task ASessionThatWasOpenedIsFoundAgainUnderItsConversationId() { - InMemoryConversationSessions sessions = new(Factory(), TimeSpan.FromMinutes(30), Clock()); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), TimeSpan.FromMinutes(30), Clock()); - ConversationSession opened = await sessions.OpenAsync("conversation-1", Token); + ConversationSession opened = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); - Assert.Same(opened, await sessions.TryGetAsync("conversation-1", Token)); + Assert.Same(opened, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); } [Fact] - public async Task ClosingAConversationWaitsForTheWordsItStillOwes() + public async Task OpeningTheSameIdAgainUnderTheSameEntryReturnsTheLiveSessionAndOneCloseReleasesIt() { - // A real store answers over a network, so a write outlives the turn that queued it. This - // session is the only thing that can wait for it, and closing is the last moment anything can. - ParkingConversationStore transcript = new(); - InMemoryConversationSessions sessions = new(Factory(transcript), TimeSpan.FromMinutes(30), Clock()); - ConversationSession session = await sessions.OpenAsync("conversation-1", Token); - - Task turn = session.RunTurnAsync("hello", Token); - await transcript.Parked; - - Task closing = sessions.CloseAsync("conversation-1", Token).AsTask(); - bool returnedEarly = await Task.WhenAny(closing, Task.Delay(200, Token)) == closing; - Assert.False(returnedEarly); - - transcript.Release(); - await closing; + // Two opens of the same id under the same entry share one build and return the same session. + using GatedSessionFactory factory = new(Factory()); + factory.Release(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), TimeSpan.FromMinutes(30), Clock()); - Assert.True(transcript.Landed); - Assert.Null(await sessions.TryGetAsync("conversation-1", Token)); - _ = await turn; - } + ConversationSession first = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + ConversationSession second = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); - [Fact] - public async Task ASessionNobodyTouchedPastTheIdleTimeoutIsClosed() - { - FakeTimeProvider clock = Clock(); - InMemoryConversationSessions sessions = new(Factory(), TimeSpan.FromMinutes(30), clock); - _ = await sessions.OpenAsync("conversation-1", Token); + Assert.Same(first, second); + Assert.Equal(1, factory.Calls); - clock.Advance(TimeSpan.FromMinutes(31)); - await sessions.SweepAsync(Token); + await sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token); - // A caller that abandons a text conversation never reaches a terminal stage, so nothing else would - // ever drop this session and the process would hold it for its whole life. - Assert.Null(await sessions.TryGetAsync("conversation-1", Token)); Assert.Equal(0, sessions.Count); + _ = await Assert.ThrowsAsync(() => first.RunTurnAsync("hello", Token)); } [Fact] - public async Task ReadingASessionPutsItsIdleClockBackToZero() - { - FakeTimeProvider clock = Clock(); - InMemoryConversationSessions sessions = new(Factory(), TimeSpan.FromMinutes(30), clock); - _ = await sessions.OpenAsync("conversation-1", Token); - - clock.Advance(TimeSpan.FromMinutes(20)); - Assert.NotNull(await sessions.TryGetAsync("conversation-1", Token)); - - // Forty minutes since it was opened, twenty since it was last read. A conversation still being had - // must not be dropped out from under the caller. - clock.Advance(TimeSpan.FromMinutes(20)); - await sessions.SweepAsync(Token); - - Assert.NotNull(await sessions.TryGetAsync("conversation-1", Token)); - } - - [Fact] - public async Task AnExpiringSessionStillHandsOverTheWordsItOwed() + public async Task ClosingAConversationWaitsForTheWordsItStillOwes() { - // The reason expiry goes through CloseAsync. An evictor that dropped the entry on its own - // would return with the write still in flight, and nothing left able to wait for it. + // A real store answers over a network, so a write outlives the turn that queued it. This + // session is the only thing that can wait for it, and closing is the last moment anything can. ParkingConversationStore transcript = new(); - FakeTimeProvider clock = Clock(); - InMemoryConversationSessions sessions = new(Factory(transcript), TimeSpan.FromMinutes(30), clock); - ConversationSession session = await sessions.OpenAsync("conversation-1", Token); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(transcript)), TimeSpan.FromMinutes(30), Clock()); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); Task turn = session.RunTurnAsync("hello", Token); await transcript.Parked; - clock.Advance(TimeSpan.FromMinutes(31)); - Task sweeping = sessions.SweepAsync(Token).AsTask(); - bool returnedEarly = await Task.WhenAny(sweeping, Task.Delay(200, Token)) == sweeping; + Task closing = sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token).AsTask(); + bool returnedEarly = await Task.WhenAny(closing, Task.Delay(200, Token)) == closing; Assert.False(returnedEarly); transcript.Release(); - await sweeping; + await closing; Assert.True(transcript.Landed); - Assert.Equal(0, sessions.Count); + Assert.Null(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); _ = await turn; } [Fact] - public async Task AnExpiringSessionWritesTheLastEventOfItsChain() + public async Task ASecondCloseOnTheSameIdWaitsForTheFirstsTeardownBeforeReturning() { - // §11 item 6 makes conversation.ended the last event of every conversation. A caller who simply stops - // replying closes no socket and reaches no terminal stage, so expiry is the only thing left - // that can write it, and a chain with no end is a permanent gap in the record of D23. - RecordingConversationObserver observer = new(); - FakeTimeProvider clock = Clock(); - InMemoryConversationSessions sessions = new( - Factory(observer: observer), TimeSpan.FromMinutes(30), clock); - _ = await sessions.OpenAsync("conversation-1", Token); - - clock.Advance(TimeSpan.FromMinutes(31)); - await sessions.SweepAsync(Token); - - Assert.Contains(observer.Kinds, kind => kind == ConversationEventKind.ConversationEnded); - } + ParkingConversationStore transcript = new(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory(transcript)), TimeSpan.FromMinutes(30), Clock()); + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + Task turn = session.RunTurnAsync("hello", Token); + await transcript.Parked; - private static FakeTimeProvider Clock() - { - return new(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); - } + Task first = sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token).AsTask(); + Task second = sessions.CloseAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token).AsTask(); - private const string Document = """ - apiVersion: agentcore/v1 - agents: - items: - - { id: only, instructions: "ok" } - entries: - main: - agent: only - """; - - private static ConversationSessionFactory Factory( - IConversationStore? transcript = null, - string? yaml = null, - IChatClient? reply = null, - IConversationObserver? observer = null) - { - AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml ?? Document); - RoutingChatClientFactory chatClients = new(reply ?? new StubChatClient()); - CompiledAgent compiled = ConfigurationCompiler.CompileAll( - document, - new AgentCompilationContext(chatClients) { ConversationStore = transcript })["main"]; - - return new ConversationSessionFactory( - compiled, - new GuardEvaluator(compiled.Configuration.Guards), - ConversationSessionFactory.CreateExtractor(compiled, chatClients), - observers: observer is null ? null : [observer]); - } + bool secondReturnedEarly = await Task.WhenAny(second, Task.Delay(200, Token)) == second; + Assert.False(secondReturnedEarly, "the second close must wait for the first's teardown, not return on its own."); - private sealed class StubChatClient : IChatClient - { - public Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken ct = default) - { - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "hello"))); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken ct = default) - { - throw new NotSupportedException(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return null; - } - - public void Dispose() - { - } - } + transcript.Release(); + await first; + await second; + _ = await turn; - /// Records the kind of every event one conversation raised. - private sealed class RecordingConversationObserver : IConversationObserver - { - private readonly List _kinds = []; - - public IReadOnlyList Kinds - { - get - { - lock (_kinds) - { - return [.. _kinds]; - } - } - } - - public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken = default) - { - lock (_kinds) - { - _kinds.Add(conversationEvent.Kind); - } - - return ValueTask.CompletedTask; - } + Assert.True(transcript.Landed, "the second close returned before the teardown it waited for was done."); + Assert.Null(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); } - - /// The default store, counting the reads that keep a conversation out of the idle sweep. } } diff --git a/tests/AgentCore.Application.Tests/Sessions/DeferredTimeProvider.cs b/tests/AgentCore.Application.Tests/Sessions/DeferredTimeProvider.cs new file mode 100644 index 00000000..13ec0330 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/DeferredTimeProvider.cs @@ -0,0 +1,44 @@ +using AgentCore.TestSupport; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// A fake clock whose timers come due on but whose callbacks wait + /// until the test runs them. + /// + internal sealed class DeferredTimeProvider(FakeTimeProvider clock) : TimeProvider + { + private readonly Queue _due = new(); + + public override long TimestampFrequency => clock.TimestampFrequency; + + public override DateTimeOffset GetUtcNow() + { + return clock.GetUtcNow(); + } + + public override long GetTimestamp() + { + return clock.GetTimestamp(); + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + return clock.CreateTimer(_ => _due.Enqueue(() => callback(state)), null, dueTime, period); + } + + /// Runs every callback that came due, in the order they came due. + /// How many callbacks ran. + public int RunDue() + { + int ran = 0; + while (_due.TryDequeue(out Action? callback)) + { + callback(); + ran++; + } + + return ran; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs b/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs new file mode 100644 index 00000000..b7d41fbf --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/GatedSessionFactory.cs @@ -0,0 +1,55 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// Stalls inside until the test releases it, so a test can hold one build open while + /// other callers race it. + /// + internal sealed class GatedSessionFactory(IConversationSessionFactory inner) : IConversationSessionFactory, IDisposable + { + private readonly ManualResetEventSlim _released = new(); + + private int _calls; + + /// Gets the signal set once has been entered. + internal ManualResetEventSlim Entered { get; } = new(); + + /// Gets the session built, or before it returned. + internal ConversationSession? Built { get; private set; } + + /// Gets how many times was entered. + internal int Calls => Volatile.Read(ref _calls); + + /// Gets what the first throws once released, instead of building. + internal Exception? FirstBuildFault { get; init; } + + internal void Release() + { + _released.Set(); + } + + public ConversationSession Create(string? conversationId = null, ConversationSessionState? state = null) + { + int call = Interlocked.Increment(ref _calls); + Entered.Set(); + _ = _released.Wait(TimeSpan.FromSeconds(10)); + + if (call == 1 && FirstBuildFault is { } fault) + { + throw fault; + } + + Built = inner.Create(conversationId, state); + return Built; + } + + public void Dispose() + { + _released.Dispose(); + Entered.Dispose(); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs b/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs new file mode 100644 index 00000000..4237106a --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/InMemoryConversationSessionsSweepTests.cs @@ -0,0 +1,310 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.TestSupport; +using Xunit; +using static AgentCore.Application.Tests.Sessions.ConversationSessionsFixture; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// The boot sweep the one session owner runs once, at startup, over the workspace root: it deletes a + /// marked folder a crashed process left behind while keeping one still inside the idle timeout, and never + /// touches a folder without the marker, a folder this owner already holds, or a symbolic link out of the root. + /// + public sealed class InMemoryConversationSessionsSweepTests : IDisposable + { + private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(10); + + private readonly string _root = Directory.CreateTempSubdirectory("agentcore-ws-sweep-").FullName; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void AFolderStampedTenMinutesAndOneSecondAgoIsDeleted() + { + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + string folder = MarkedStampedFolder("stale", clock, TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(1)); + + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.False(Directory.Exists(folder)); + } + + [Fact] + public void AFolderStampedNineMinutesFiftyNineSecondsAgoIsKept() + { + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + string folder = MarkedStampedFolder("fresh", clock, TimeSpan.FromMinutes(9) + TimeSpan.FromSeconds(59)); + + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.True(Directory.Exists(folder)); + } + + [Fact] + public void DeletingAStaleFolderAlsoDeletesItsSiblingMarker() + { + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + string folder = MarkedStampedFolder("stale", clock, TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(1)); + + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.False(File.Exists(ConversationWorkspace.MarkerPathFor(folder))); + } + + [Fact] + public void AMarkerWhoseFolderIsStillFreshSurvivesEvenIfTheMarkersOwnStampIsOld() + { + // A marker is written once, at Create, and never touched again on reopen; a long-lived, actively + // used folder's own stamp keeps moving while its marker's does not. The orphan-marker cleanup must + // key off whether the folder exists, never off the marker's own age, or it would delete the marker + // out from under a folder that is still very much alive. + FakeTimeProvider clock = Clock(); + string folder = MarkedStampedFolder("alive", clock, TimeSpan.FromSeconds(1)); + File.SetLastWriteTimeUtc( + ConversationWorkspace.MarkerPathFor(folder), clock.GetUtcNow().UtcDateTime - TimeSpan.FromDays(365)); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.True(Directory.Exists(folder)); + Assert.True(File.Exists(ConversationWorkspace.MarkerPathFor(folder))); + } + + [Fact] + public void AMarkerWithNoFolderIsDeletedOnceItIsOldEnough() + { + // The folder it belonged to is gone — deleted out of band, say — but its marker survived. Nothing + // stamps a marker on its own, so its age is frozen at creation; once that crosses the idle timeout + // it is as stale as an ordinary folder would be. + FakeTimeProvider clock = Clock(); + string marker = ConversationWorkspace.MarkerPathFor(Path.Combine(_root, "orphan")); + File.WriteAllText(marker, string.Empty); + File.SetLastWriteTimeUtc(marker, clock.GetUtcNow().UtcDateTime - TimeSpan.FromMinutes(10) - TimeSpan.FromSeconds(1)); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.False(File.Exists(marker)); + } + + [Fact] + public void AMarkerWithNoFolderIsKeptWhileItIsStillFresh() + { + FakeTimeProvider clock = Clock(); + string marker = ConversationWorkspace.MarkerPathFor(Path.Combine(_root, "orphan")); + File.WriteAllText(marker, string.Empty); + File.SetLastWriteTimeUtc(marker, clock.GetUtcNow().UtcDateTime - TimeSpan.FromMinutes(9) - TimeSpan.FromSeconds(59)); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.True(File.Exists(marker)); + } + + [Fact] + public void AFolderWithoutTheMarkerIsKeptNoMatterHowOld() + { + // A workspace root pointed at a folder used for anything else — the current directory, a home + // directory, a shared volume — must never lose a folder AgentCore did not make. Only a folder + // ConversationWorkspace.Create wrote the marker into is ever a candidate for deletion. + FakeTimeProvider clock = Clock(); + string unrelated = Directory.CreateDirectory(Path.Combine(_root, ".config")).FullName; + File.WriteAllText(Path.Combine(unrelated, "settings.json"), "{}"); + Directory.SetLastWriteTimeUtc(unrelated, clock.GetUtcNow().UtcDateTime - TimeSpan.FromDays(365)); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.True(Directory.Exists(unrelated)); + Assert.True(File.Exists(Path.Combine(unrelated, "settings.json"))); + } + + [Fact] + public void NestedSymlinksInsideASweptFolderAreNotFollowed() + { + string outside = Directory.CreateTempSubdirectory("agentcore-ws-sweep-outside-").FullName; + try + { + string precious = Path.Combine(outside, "precious.txt"); + File.WriteAllText(precious, "x"); + string outsideSub = Directory.CreateDirectory(Path.Combine(outside, "sub")).FullName; + File.WriteAllText(Path.Combine(outsideSub, "deep.txt"), "y"); + + FakeTimeProvider clock = Clock(); + string stale = Directory.CreateDirectory(Path.Combine(_root, "stale")).FullName; + File.WriteAllText(ConversationWorkspace.MarkerPathFor(stale), string.Empty); + string nested = Directory.CreateDirectory(Path.Combine(stale, "a", "b")).FullName; + Directory.CreateSymbolicLink(Path.Combine(stale, "dirlink"), outside); + Directory.CreateSymbolicLink(Path.Combine(nested, "deeplink"), outsideSub); + File.CreateSymbolicLink(Path.Combine(stale, "filelink"), precious); + Directory.SetLastWriteTimeUtc(stale, clock.GetUtcNow().UtcDateTime - TimeSpan.FromHours(1)); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.False(Directory.Exists(stale)); + Assert.True(File.Exists(precious)); + Assert.True(File.Exists(Path.Combine(outsideSub, "deep.txt"))); + } + finally + { + Directory.Delete(outside, recursive: true); + } + } + + [Fact] + public async Task AFolderThisOwnerAlreadyHoldsIsKeptRegardlessOfItsStamp() + { + FakeTimeProvider clock = Clock(); + using InMemoryConversationSessions sessions = new( + SingleEntrySessionFactories.Of(Factory(workspaceRoot: _root, timeProvider: clock)), IdleTimeout, clock); + + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + string folder = Path.Combine(_root, "conversation-1"); + Directory.SetLastWriteTimeUtc(folder, clock.GetUtcNow().UtcDateTime - TimeSpan.FromDays(1)); + + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.True(Directory.Exists(folder)); + } + + [Fact] + public async Task AHeldSessionBetweenTurnsSurvivesAnotherServersSweepAfterAShortTurn() + { + // A lookup between turns (the HTTP post-turn re-fetch, a voice keep-alive) restarts the idle clock; + // it must restart the folder's stamp too, or another server sharing the root can sweep a session + // that is still held here but has run no turn recently enough to have stamped it itself. + FakeTimeProvider clock = Clock(); + ParkingConversationStore store = new(); + using InMemoryConversationSessions serverA = new( + SingleEntrySessionFactories.Of(Factory(store, workspaceRoot: _root, timeProvider: clock)), IdleTimeout, clock); + ConversationSession session = await serverA.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "c1", null, Token); + + Task turn = session.RunTurnAsync("hello", Token); + await store.Parked; + clock.Advance(TimeSpan.FromMinutes(5)); + store.Release(); + await turn; + _ = await serverA.TryGetAsync(SingleEntrySessionFactories.MainEntry, "c1", Token); + + clock.Advance(TimeSpan.FromMinutes(7)); + Assert.Equal(1, serverA.Count); + + using InMemoryConversationSessions serverB = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + serverB.SweepWorkspaceRoot(_root, logger: null); + + Assert.Equal(1, serverA.Count); + Assert.True(Directory.Exists(session.Workspace!)); + } + + [Fact] + public async Task ALongTurnWithNoBackgroundChildSurvivesASweepMidTurn() + { + // A turn can run for more than one idle timeout. The single stamp taken the moment it first crosses + // the timeout is not enough on its own: a sweep that lands well after that moment, but still mid-turn, + // must still see this folder as active. + FakeTimeProvider clock = Clock(); + ParkingConversationStore store = new(); + using InMemoryConversationSessions serverA = new( + SingleEntrySessionFactories.Of(Factory(store, workspaceRoot: _root, timeProvider: clock)), IdleTimeout, clock); + ConversationSession session = await serverA.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "c1", null, Token); + + Task turn = session.RunTurnAsync("hello", Token); + await store.Parked; + clock.Advance(IdleTimeout); + clock.Advance(IdleTimeout + TimeSpan.FromSeconds(1)); + + using InMemoryConversationSessions serverB = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + serverB.SweepWorkspaceRoot(_root, logger: null); + + Assert.False(turn.IsCompleted); + Assert.True(Directory.Exists(session.Workspace!)); + + store.Release(); + await turn; + } + + [Fact] + public async Task AReopenedOldFolderSurvivesASweepBeforeItsFirstTurn() + { + // Resuming a conversation reuses its old folder: CreateDirectory on a folder that already exists + // does not touch its last-write time, so the reopen itself, not the first turn, must stamp it. + FakeTimeProvider clock = Clock(); + string folder = Directory.CreateDirectory(Path.Combine(_root, "c1")).FullName; + File.WriteAllText(ConversationWorkspace.MarkerPathFor(folder), string.Empty); + File.WriteAllText(Path.Combine(folder, "notes.md"), "kept across unload?"); + Directory.SetLastWriteTimeUtc(folder, clock.GetUtcNow().UtcDateTime - TimeSpan.FromHours(1)); + + using InMemoryConversationSessions serverA = new( + SingleEntrySessionFactories.Of(Factory(workspaceRoot: _root, timeProvider: clock)), IdleTimeout, clock); + _ = await serverA.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "c1", null, Token); + + using InMemoryConversationSessions serverB = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + serverB.SweepWorkspaceRoot(_root, logger: null); + + Assert.Equal(1, serverA.Count); + Assert.True(Directory.Exists(folder)); + Assert.True(File.Exists(Path.Combine(folder, "notes.md"))); + } + + [Fact] + public void AFolderJustCreatedSurvivesEvenWhenTheHostClockRunsAheadOfTheWallClock() + { + // Create and the sweep both read the one TimeProvider a host gives them, never the real wall clock, + // so a host clock that drifts ahead of real time never makes a brand-new folder look stale. + FakeTimeProvider clock = new(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(11)); + ConversationWorkspace workspace = ConversationWorkspace.Create(_root, "c1", clock, logger: null); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.True(Directory.Exists(workspace.Path)); + } + + [Fact] + public void ASymbolicLinkUnderTheRootIsSkippedEvenWhenItsOwnStampIsOld() + { + // The link's own last-write time, not the target's: SetLastWriteTimeUtc on a symlink stamps the + // link (proven against this runtime), so an old stamp here is what would make the sweep try to + // delete it if the symlink skip were missing. + string target = Directory.CreateTempSubdirectory("agentcore-ws-sweep-target-").FullName; + try + { + string link = Path.Combine(_root, "linked"); + Directory.CreateSymbolicLink(link, target); + File.WriteAllText(Path.Combine(target, "kept.txt"), "kept"); + + FakeTimeProvider clock = Clock(); + Directory.SetLastWriteTimeUtc(link, clock.GetUtcNow().UtcDateTime - TimeSpan.FromDays(1)); + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(Factory()), IdleTimeout, clock); + + sessions.SweepWorkspaceRoot(_root, logger: null); + + Assert.Equal(target, new DirectoryInfo(link).LinkTarget); + Assert.True(File.Exists(Path.Combine(target, "kept.txt"))); + } + finally + { + Directory.Delete(target, recursive: true); + } + } + + private string MarkedStampedFolder(string name, FakeTimeProvider clock, TimeSpan age) + { + string folder = Directory.CreateDirectory(Path.Combine(_root, name)).FullName; + File.WriteAllText(ConversationWorkspace.MarkerPathFor(folder), string.Empty); + Directory.SetLastWriteTimeUtc(folder, clock.GetUtcNow().UtcDateTime - age); + return folder; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs b/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs new file mode 100644 index 00000000..432b8778 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/OwnThread.cs @@ -0,0 +1,16 @@ +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// Starts an open that will stall in on a thread of its own. + /// + internal static class OwnThread + { + /// Runs on a dedicated thread. + /// The result of the open. + /// The open to run. + /// Cancels the start. + /// The task of the open. + internal static Task Run(Func> open, CancellationToken cancellationToken) => + Task.Factory.StartNew(open, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/ParkingRewriteStore.cs b/tests/AgentCore.Application.Tests/Sessions/ParkingRewriteStore.cs new file mode 100644 index 00000000..ea9ce755 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/ParkingRewriteStore.cs @@ -0,0 +1,34 @@ +using AgentCore.Application.Conversation.Memory; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Sessions +{ + /// + /// A store that holds every rewrite of a reply open until a test releases it. A rewrite is queued after the + /// turn ends, so it is a write a session still owes with no turn running. + /// + internal sealed class ParkingRewriteStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a task that completes once a rewrite is parked. + public Task Parked => _entered.Task; + + /// Lets every parked rewrite finish. + public void Release() + { + _ = _release.TrySetResult(); + } + + /// + public override async ValueTask RewriteAsync( + string conversationId, string messageId, ChatMessage content, CancellationToken cancellationToken = default) + { + _ = _entered.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + await base.RewriteAsync(conversationId, messageId, content, cancellationToken); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs b/tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs new file mode 100644 index 00000000..7988bd86 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Sessions/RecordingConversationObserver.cs @@ -0,0 +1,41 @@ +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; + +namespace AgentCore.Application.Tests.Sessions +{ + /// Records the kind of every event one conversation raised. + internal sealed class RecordingConversationObserver : IConversationObserver + { + private readonly List _kinds = []; + private readonly TaskCompletionSource _ended = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public IReadOnlyList Kinds + { + get + { + lock (_kinds) + { + return [.. _kinds]; + } + } + } + + /// Gets a task that completes once conversation.ended arrives. + public Task Ended => _ended.Task; + + public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken = default) + { + lock (_kinds) + { + _kinds.Add(conversationEvent.Kind); + } + + if (conversationEvent.Kind == ConversationEventKind.ConversationEnded) + { + _ = _ended.TrySetResult(); + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.Application.Tests/Tools/BindingToolTests.cs b/tests/AgentCore.Application.Tests/Tools/BindingToolTests.cs index ee56e43a..c1a50062 100644 --- a/tests/AgentCore.Application.Tests/Tools/BindingToolTests.cs +++ b/tests/AgentCore.Application.Tests/Tools/BindingToolTests.cs @@ -149,13 +149,10 @@ public async Task TheDeclaredSchema_ReachesTheModelUnchanged() Assert.True(JsonNode.DeepEquals(CreateCase.Parameters, JsonNode.Parse(function.JsonSchema.GetRawText()))); } - // --------------------------------------------------------------------------------------------- - // Section 8.7: a tool returns an error result and does not throw. Task 7a moved the - // classification that makes that true off DeclaredTool and into - // AuditingFunctionInvokingChatClient, so calling the bare tool directly now sees the exception - // the host delegate threw. See AuditingFunctionInvokingChatClientErrorPolicyTests and - // ConversationSessionTests for the end-to-end guarantee. - // --------------------------------------------------------------------------------------------- + // Section 8.7: a tool returns an error result and does not throw. AuditingFunctionInvokingChatClient + // makes that true, not DeclaredTool, so calling the bare tool directly sees the exception the host + // delegate threw. See AuditingFunctionInvokingChatClientErrorPolicyTests and + // ConversationSessionToolFailureTests for the end-to-end guarantee. [Fact] public async Task AHostDelegateThatThrows_PropagatesForTheMiddlewareToClassify() { diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs new file mode 100644 index 00000000..eaaf178e --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderCommitTests.cs @@ -0,0 +1,309 @@ +using System.Text.Json; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Runtime; +using AgentCore.Application.Runtime.Harness; +using AgentCore.Application.Runtime.Turn; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Transcript.AgentCoreChatHistoryProviderTestSupport; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// The provider as the only door to store 1: the framework's hook stages, and CommitTurn and + /// RewriteReply are the durable writes (docs/handoff/2026-09-22-maf-native-engine-design.md). + /// + public sealed class AgentCoreChatHistoryProviderCommitTests + { + private const string LoopYaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: coder, instructions: "fix bugs", todos: true, loop: { maxRounds: 3, until: [{ todos: {} }] } } + entries: + main: + agent: coder + """; + + private const string AutoApprovedYaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: send_email, kind: builtin, uses: test.send, description: "Send an email." } + agents: + items: + - id: only + instructions: "send the mail" + tools: [ send_email ] + approval: { auto: [ send_email ] } + entries: + main: + agent: only + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Design section 7 item 1 (owner ruling): a loop: entry calls the hook once per iteration inside one + // turn; staging accumulates and the commit writes all of it once. maxRounds: 3 with an open todo runs + // three iterations (LoopCompilationTests), each a call, its result and a line. + [Fact] + public async Task CommitTurn_LoopEntry_StagesEveryIteration_AndWritesThemInOneAppend() + { + // Arrange + RecordingConversationStore store = new(); + ToolCallingChatClient client = new( + "added", + new Dictionary(StringComparer.Ordinal) + { + ["todos"] = new object[] { new Dictionary(StringComparer.Ordinal) { ["title"] = "T" } }, + }); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(LoopYaml), + new AgentCompilationContext(new FakeChatClientFactory(client)) { ConversationStore = store })["main"]; + AIAgent agent = compiled.Agents["coder"]; + AgentSession session = await OpenAsync(compiled, agent, store); + + // Act + _ = await agent.RunAsync("fix it", session, cancellationToken: Ct); + IReadOnlyList staged = compiled.History.Staged(session); + _ = compiled.History.CommitTurn(session, new TurnCommit(new ChatMessage(ChatRole.User, "fix it"))); + await compiled.History.DrainAsync(session); + + // Assert + string[] iteration = ["assistant:call", "tool:result", "assistant:text"]; + Assert.Equal([.. iteration, .. iteration, .. iteration], Kinds(staged)); + Assert.Equal(1, store.Appends); + Assert.Equal(["user:text", .. iteration, .. iteration, .. iteration], Kinds(store.Rows.Select(row => row.Content))); + Assert.Empty(compiled.History.Staged(session)); + } + + // Design section 7 item 1 (owner ruling): an approval re-entry runs the agent again inside the same + // turn. The first run ends on the approval request, the second runs the tool and answers "done.". + [Fact] + public async Task CommitTurn_ApprovalReEntry_StagesBothRuns_AndWritesThemInOneAppend() + { + // Arrange + RecordingConversationStore store = new(); + ToolCallingChatClient client = new("done.", new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" }); + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(AutoApprovedYaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(new FakeChatClientFactory(client)) + { + ConversationStore = store, + Tools = TestToolRegistry.From(document, declared => declared.Uses == "test.send" ? SendEmail() : null, Ct), + })["main"]; + AIAgent agent = compiled.Agents["only"]; + AgentSession session = await OpenAsync(compiled, agent, store); + + // Act + _ = await agent.RunAsync("send it", session, cancellationToken: Ct); + IReadOnlyList staged = compiled.History.Staged(session); + _ = compiled.History.CommitTurn(session, new TurnCommit(new ChatMessage(ChatRole.User, "send it"))); + await compiled.History.DrainAsync(session); + + // Assert + Assert.Contains(staged.SelectMany(message => message.Contents), content => content is ToolApprovalRequestContent); + Assert.Equal("done.", staged[^1].Text); + Assert.Equal(1, store.Appends); + Assert.Equal(staged.Count + 1, store.Rows.Count); + Assert.Equal("done.", store.Rows[^1].Content.Text); + } + + // Probe G1: an AIContextProvider's system line reaches the model, and never the committed turn. + [Fact] + public async Task CommitTurn_ContextProviderSystemLine_ReachesTheModelButNotTheTranscript() + { + // Arrange + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession _) = await NewConversation(); + RequestRecordingChatClient model = new("x"); + ChatClientAgent agent = new( + model, + new ChatClientAgentOptions { ChatHistoryProvider = provider, AIContextProviders = [new SystemLineProvider()] }); + AgentSession session = await agent.CreateSessionAsync(Ct); + _ = provider.BeginConversation(session, ConversationId, []); + provider.BeginTurn(session, 0); + + // Act + _ = await agent.RunAsync("hi", session, cancellationToken: Ct); + _ = provider.CommitTurn(session, new TurnCommit(new ChatMessage(ChatRole.User, "hi"))); + await provider.DrainAsync(session); + + // Assert + Assert.Contains("system:Today is Tuesday.", model.Requests[0]); + Assert.Equal(["user:hi", "assistant:x"], store.Rows.Select(row => $"{row.Content.Role}:{row.Content.Text}")); + } + + // Probe G4: a background child carries the parent's TurnRegistry entry and BlobOwnerKey, but not the + // provider's own key, so it stages nothing and commits nothing. + [Fact] + public async Task BackgroundChild_WithoutTheProviderKey_StagesAndCommitsNothing() + { + // Arrange + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession _) = await NewConversation(); + using SequencedChatClient model = new("child words"); + BackgroundChildAgent child = new(new ChatClientAgent(model, new ChatClientAgentOptions { ChatHistoryProvider = provider })); + TurnInvocation parent = new() { ConversationId = "parent-conv", TurnIndex = 0, Stage = string.Empty }; + AgentSession? childSession = null; + Outer outer = new(async () => + { + childSession = await child.CreateSessionAsync(Ct); + _ = await child.RunAsync("do it", childSession, cancellationToken: Ct); + }); + + // Act + _ = await outer.RunAsync("go", options: parent.RunOptions(), cancellationToken: Ct); + TurnWrite? committed = provider.CommitTurn(childSession!, new TurnCommit(new ChatMessage(ChatRole.User, "do it"))); + await provider.DrainAsync(childSession!); + + // Assert + Assert.NotNull(TurnRegistry.For(childSession)); + Assert.True(childSession!.StateBag.TryGetValue(BlobOwnerKey.Value, out string? _)); + Assert.False(childSession.StateBag.TryGetValue(StateKey, out string? _)); + Assert.Empty(provider.Staged(childSession)); + Assert.Null(committed); + Assert.Empty(store.Rows); + } + + // Design section 3, "Cut mid-text": the user, the finished tool pairs, and exactly the shown text. + [Fact] + public async Task CommitTurn_CutMidText_KeepsTheFinishedPairAndTheShownText() + { + // Arrange + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); + provider.BeginTurn(session, 0); + AgentResponse seen = new( + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "lookup")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "lookup")]), + new ChatMessage(ChatRole.Assistant, "Hello there"), + ]); + + // Act + _ = provider.CommitTurn( + session, + new TurnCommit(new ChatMessage(ChatRole.User, "hi")) { Seen = seen, Cut = new TurnCut("Hel", TimeSpan.FromMilliseconds(300)) }); + await provider.DrainAsync(session); + + // Assert + Assert.Equal(["user:text", "assistant:call", "tool:result", "assistant:text"], Kinds(store.Rows.Select(row => row.Content))); + Assert.Equal("Hel", store.Rows[^1].Content.Text); + } + + // Design section 3, Cut table: a sealed turn is rewritten, never appended to; an older turn answers false. + [Fact] + public async Task RewriteReply_OnlyTheLastTurnIsRewritten_AndNothingIsAppended() + { + // Arrange + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); + AppendTurn(provider, session, turnIndex: 0, "one", "first"); + AppendTurn(provider, session, turnIndex: 1, "two", "second reply"); + + // Act + bool older = provider.RewriteReply(session, 0, "fir"); + bool last = provider.RewriteReply(session, 1, "second"); + await provider.DrainAsync(session); + + // Assert + Assert.False(older); + Assert.True(last); + Assert.Equal(2, store.Appends); + Assert.Equal(["one", "first", "two", "second"], store.Live(ConversationId).Select(row => row.Content.Text)); + } + + // CommitTurn: the reply id is null when the user's message is all the turn wrote. + [Fact] + public async Task CommitTurn_CutBeforeAnyWord_NamesTheUserMessageAndNoReply() + { + // Arrange + (AgentCoreChatHistoryProvider provider, RecordingConversationStore store, StubSession session) = await NewConversation(); + provider.BeginTurn(session, 0); + + // Act + (string UserMessageId, string? ReplyMessageId)? ids = provider.CommitTurn( + session, new TurnCommit(new ChatMessage(ChatRole.User, "hi")) { Cut = new TurnCut(string.Empty, null) })?.Ids; + await provider.DrainAsync(session); + + // Assert + ConversationMessage only = Assert.Single(store.Rows); + Assert.Equal((only.MessageId, (string?)null), ids); + } + + private static async Task OpenAsync(CompiledAgent compiled, AIAgent agent, RecordingConversationStore store) + { + _ = await store.CreateAsync(ConversationId, Ct); + AgentSession session = await agent.CreateSessionAsync(Ct); + _ = compiled.History.BeginConversation(session, ConversationId, []); + compiled.History.BeginTurn(session, 0); + return session; + } + + private static List Kinds(IEnumerable messages) + { + return [.. messages.Select(message => $"{message.Role}:{Kind(message)}")]; + } + + private static string Kind(ChatMessage message) + { + return message.Contents.Any(content => content is FunctionCallContent) ? "call" + : message.Contents.Any(content => content is FunctionResultContent) ? "result" + : "text"; + } + + private static ApprovalRequiredAIFunction SendEmail() + { + return new(AIFunctionFactory.Create((string to) => "sent", "send_email", "Send an email.")); + } + + private sealed class SystemLineProvider : AIContextProvider + { + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + return new(new AIContext { Messages = [new ChatMessage(ChatRole.System, "Today is Tuesday.")] }); + } + } + + /// Runs a body inside a run of its own, so the framework's current run context is the parent's. + private sealed class Outer(Func body) : AIAgent + { + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + protected override async Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + await body(); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, "ok")); + } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs index 991bdfca..357977a4 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTestSupport.cs @@ -16,12 +16,8 @@ internal static class AgentCoreChatHistoryProviderTestSupport { internal const string ConversationId = "conversation-1"; - /// - /// The provider's MAF-default state key: its own type name. The transcript no longer files - /// under it — or anywhere in the bag — and a change still renames what the collision checks - /// compare, so it stays pinned here rather than inlined. - /// - internal const string StateKey = "AgentCoreChatHistoryProvider"; + /// The key that binds a session to its conversation (design section 2, "StateKeys"). + internal const string StateKey = "agentcore.history"; /// /// Opens one conversation on a fresh session, the way ConversationSession does at conversation start: the row is @@ -38,7 +34,7 @@ internal static class AgentCoreChatHistoryProviderTestSupport return (provider, store, session); } - /// Writes one turn the way ConversationSession does: name the turn, then append it. + /// Writes one turn the way ConversationTurnAgent does: name the turn, then commit it. internal static void AppendTurn( AgentCoreChatHistoryProvider provider, AgentSession session, @@ -47,9 +43,9 @@ internal static void AppendTurn( string replied) { provider.BeginTurn(session, turnIndex); - _ = provider.AppendTurn( + _ = provider.CommitTurn( session, - [new ChatMessage(ChatRole.User, said), new ChatMessage(ChatRole.Assistant, replied)]); + new TurnCommit(new ChatMessage(ChatRole.User, said)) { Seen = new AgentResponse(new ChatMessage(ChatRole.Assistant, replied)) }); } diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs index f5701c68..8ed60664 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTests.cs @@ -79,14 +79,9 @@ public async Task AppendTurn_FailedTurn_IsStored() store.Rows.Select(row => row.Content.Text)); } - /// - /// The framework offers to store a finished run, and this provider declines. Measured on - /// Microsoft.Agents.AI 1.17.0, that hook stores the request verbatim — reminder and all — and is - /// never called at all for a run the caller cut short, which is every barge-in. ConversationSession - /// writes the turn it shaped instead. - /// + // Design section 2: the hook stages and writes nothing durable; section 7 item 1: a read never returns staged messages. [Fact] - public async Task StoreChatHistory_FinishedRun_StoresNothing() + public async Task StoreChatHistory_FinishedRun_StagesTheResponseAndStoresNothing() { // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore? store, StubSession? session) = await NewConversation(); @@ -107,6 +102,7 @@ [new ChatMessage(ChatRole.Assistant, "it ships Friday")]), await provider.DrainAsync(session); Assert.Empty(store.Rows); Assert.Empty(await ProvideAsync(provider, session)); + Assert.Equal(["it ships Friday"], provider.Staged(session).Select(message => message.Text)); } [Fact] @@ -121,12 +117,12 @@ public async Task AppendTurn_ConcurrentAppends_LosesNoMessage() async () => { await start.Task; - _ = provider.AppendTurn( + _ = provider.CommitTurn( session, - [ - new ChatMessage(ChatRole.User, $"said {index}"), - new ChatMessage(ChatRole.Assistant, $"replied {index}"), - ]); + new TurnCommit(new ChatMessage(ChatRole.User, $"said {index}")) + { + Seen = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"replied {index}")), + }); }, TestContext.Current.CancellationToken))]; @@ -159,14 +155,9 @@ public async Task ProvideChatHistory_TwoConcurrentSessions_DoNotMix() Assert.Equal(["b said", "b heard"], (await ProvideAsync(provider, second)).Select(message => message.Text)); } - /// - /// The framework validates this set at agent construction and again on every run, and refuses a - /// collision. The value is MAF's default — the provider's own type name — and nothing files - /// under it anymore, but a change still renames what the collision checks compare, so it stays - /// pinned rather than inlined. - /// + // Design section 2: StateKeys = ["agentcore.history"]. [Fact] - public void StateKeys_IsTheSingleProviderTypeNameKey() + public void StateKeys_IsTheConversationKey() { // Arrange AgentCoreChatHistoryProvider provider = new(); @@ -175,8 +166,9 @@ public void StateKeys_IsTheSingleProviderTypeNameKey() Assert.Equal([StateKey], provider.StateKeys); } + // Design section 2: BeginConversation files the conversation id under the provider's key, and nothing else enters the bag. [Fact] - public async Task AppendTurn_LeavesTheSessionStateBagEmpty() + public async Task AppendTurn_LeavesOnlyTheConversationKeyInTheStateBag() { // Arrange (AgentCoreChatHistoryProvider? provider, RecordingConversationStore _, StubSession? session) = await NewConversation(); @@ -185,7 +177,9 @@ public async Task AppendTurn_LeavesTheSessionStateBagEmpty() AppendTurn(provider, session, turnIndex: 0, "order 41?", "it ships Friday"); // Assert - Assert.Equal(0, session.StateBag.Count); + Assert.Equal(1, session.StateBag.Count); + Assert.True(session.StateBag.TryGetValue(StateKey, out string? conversationId)); + Assert.Equal(ConversationId, conversationId); Assert.Equal(["order 41?", "it ships Friday"], provider.Read(session).Select(message => message.Text)); } @@ -234,15 +228,38 @@ public async Task BeginConversation_BackingStoreThrows_TellsTheReporterWhichTurn // Arrange AgentCoreChatHistoryProvider provider = new(new ThrowingConversationStore()); StubSession session = new(); - List dropped = []; - _ = provider.BeginConversation(session, ConversationId, [], (turnIndex, _) => dropped.Add(turnIndex)); + DroppedTurns dropped = new(); + _ = provider.BeginConversation(session, ConversationId, [], dropped); // Act AppendTurn(provider, session, turnIndex: 3, "hello", "hi there"); // Assert await provider.DrainAsync(session); - Assert.Equal([3], dropped); + Assert.Equal([3], dropped.Turns); + } + + private sealed class DroppedTurns : ITranscriptLossCounter + { + public List Turns { get; } = []; + + public void Dropped(int turnIndex, IReadOnlyList appended, Exception exception) + { + Turns.Add(turnIndex); + } + + public void Withdrew(IReadOnlyList messageIds) + { + } + + public ValueTask JudgeAsync(AgentSession session, CancellationToken cancellationToken) + { + return ValueTask.FromResult(null); + } + + public void Realigned(TranscriptLossVerdict verdict) + { + } } } } diff --git a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTruncateTests.cs b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTruncateTests.cs index 3f50dc62..d6fd33b3 100644 --- a/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTruncateTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/AgentCoreChatHistoryProviderTruncateTests.cs @@ -1,6 +1,7 @@ using AgentCore.Application.Conversation.Memory; using AgentCore.Application.Transcript; using AgentCore.TestSupport; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Xunit; using static AgentCore.Application.Tests.Transcript.AgentCoreChatHistoryProviderTestSupport; @@ -14,7 +15,7 @@ namespace AgentCore.Application.Tests.Transcript public sealed class AgentCoreChatHistoryProviderTruncateTests { [Fact] - public async Task TruncateLastReply_LastAssistantMessage_RewritesOnlyThatMessage() + public async Task RewriteReply_LastAssistantMessage_RewritesOnlyThatMessage() { // Arrange (AgentCoreChatHistoryProvider? provider, Fakes.RecordingConversationStore? store, StubSession? session) = await NewConversation(); @@ -22,7 +23,7 @@ public async Task TruncateLastReply_LastAssistantMessage_RewritesOnlyThatMessage AppendTurn(provider, session, turnIndex: 1, "order 41?", "it ships Friday from the depot"); // Act - bool cut = provider.TruncateLastReply(session, "it ships", TimeSpan.FromMilliseconds(420)); + bool cut = provider.RewriteReply(session, 1, "it ships"); // Assert Assert.True(cut); @@ -42,7 +43,7 @@ public async Task ProvideChatHistory_AfterTruncate_ReturnsHeardTextNotProducedTe AppendTurn(provider, session, turnIndex: 0, "order 41?", "it ships Friday from the depot"); // Act - _ = provider.TruncateLastReply(session, "it ships Fri", TimeSpan.FromMilliseconds(300)); + _ = provider.RewriteReply(session, 0, "it ships Fri"); // Assert IReadOnlyList history = await ProvideAsync(provider, session); @@ -55,7 +56,7 @@ public async Task ProvideChatHistory_AfterTruncate_ReturnsHeardTextNotProducedTe /// and leave the record holding words the caller never heard. /// [Fact] - public async Task TruncateLastReply_WhileTheAppendIsStillWriting_ReachesTheStoreAfterIt() + public async Task RewriteReply_WhileTheAppendIsStillWriting_ReachesTheStoreAfterIt() { // Arrange BlockingConversationStore store = new(); @@ -70,7 +71,7 @@ public async Task TruncateLastReply_WhileTheAppendIsStillWriting_ReachesTheStore await store.Entered; // Act - bool cut = provider.TruncateLastReply(session, "it ships", TimeSpan.FromMilliseconds(90)); + bool cut = provider.RewriteReply(session, 1, "it ships"); store.Release(); await provider.DrainAsync(session); @@ -82,35 +83,12 @@ public async Task TruncateLastReply_WhileTheAppendIsStillWriting_ReachesTheStore } /// - /// The held prompt of item 6a: the vendor is still speaking turn 0 when turn 1 begins, so the - /// reply the caller was hearing belongs to the turn before the one now open. ConversationSession decides - /// that a barge-in reaches it; the provider must not refuse because the turn moved on. + /// A model routinely writes a line and puts the tool call it announces on the same message, then + /// answers in a second step. The first step's words stay before their tool call and only the second + /// step is cut to what was heard (owner ruling 2026-09-23). /// [Fact] - public async Task TruncateLastReply_AfterTheNextTurnOpened_CutsTheReplyTheCallerWasHearing() - { - // Arrange - (AgentCoreChatHistoryProvider? provider, Fakes.RecordingConversationStore _, StubSession? session) = await NewConversation(); - AppendTurn(provider, session, turnIndex: 0, "hello", "hi there caller"); - provider.BeginTurn(session, turnIndex: 1); - - // Act - bool cut = provider.TruncateLastReply(session, "hi there", TimeSpan.FromMilliseconds(300)); - - // Assert - Assert.True(cut); - IReadOnlyList history = await ProvideAsync(provider, session); - Assert.Equal(["hello", "hi there"], history.Select(message => message.Text)); - } - - /// - /// A model routinely writes a line and puts the tool call it announces on the same message, and a - /// graph row writes one reply for each node. The caller heard as much of the turn as the vendor - /// played and nothing else, so no earlier word of that turn may survive the cut as text the - /// caller is recorded as having heard. - /// - [Fact] - public async Task TruncateLastReply_TurnWithProseBesideAToolCall_DropsEveryWordButTheHeardOnes() + public async Task RewriteReply_TurnWithProseBesideAToolCall_KeepsTheProseAndCutsTheLastStep() { // Arrange (AgentCoreChatHistoryProvider? provider, Fakes.RecordingConversationStore? store, StubSession? session) = await NewConversation(); @@ -118,40 +96,43 @@ public async Task TruncateLastReply_TurnWithProseBesideAToolCall_DropsEveryWordB ChatMessage announced = new( ChatRole.Assistant, [new TextContent("Let me check that for you"), new FunctionCallContent("conversation-1", "lookup")]); - _ = provider.AppendTurn( + _ = provider.CommitTurn( session, - [ - new ChatMessage(ChatRole.User, "how much?"), - announced, - new ChatMessage(ChatRole.Tool, [new FunctionResultContent("conversation-1", "50")]), - new ChatMessage(ChatRole.Assistant, "the price is fifty"), - ]); + new TurnCommit(new ChatMessage(ChatRole.User, "how much?")) + { + Seen = new AgentResponse( + [ + announced, + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("conversation-1", "50")]), + new ChatMessage(ChatRole.Assistant, "the price is fifty"), + ]), + }); // Act - bool cut = provider.TruncateLastReply(session, "the price", TimeSpan.FromMilliseconds(400)); + bool cut = provider.RewriteReply(session, 0, "Let me check that for youthe price"); // Assert Assert.True(cut); IReadOnlyList history = await ProvideAsync(provider, session); - Assert.Equal(["how much?", string.Empty, string.Empty, "the price"], history.Select(m => m.Text)); + Assert.Equal(["how much?", "Let me check that for you", string.Empty, "the price"], history.Select(m => m.Text)); // The side effect ran, so the pair stays. That is the rule the cut must not break. Assert.Contains(history, m => m.Contents.OfType().Any()); Assert.Contains(history, m => m.Contents.OfType().Any()); await provider.DrainAsync(session); - Assert.Equal([1, 3], store.Rewrites.Select(rewrite => rewrite.Ordinal)); + Assert.Equal([3], store.Rewrites.Select(rewrite => rewrite.Ordinal)); } [Fact] - public async Task TruncateLastReply_BeforeAnyReplyExists_NoOps() + public async Task RewriteReply_BeforeAnyReplyExists_NoOps() { // Arrange (AgentCoreChatHistoryProvider? provider, Fakes.RecordingConversationStore? store, StubSession? session) = await NewConversation(); provider.BeginTurn(session, turnIndex: 0); // Act - bool cut = provider.TruncateLastReply(session, "nothing was said", TimeSpan.Zero); + bool cut = provider.RewriteReply(session, 0, "nothing was said"); // Assert Assert.False(cut); @@ -159,6 +140,56 @@ public async Task TruncateLastReply_BeforeAnyReplyExists_NoOps() Assert.Empty(await ProvideAsync(provider, session)); } + [Fact] + public async Task TruncateFrom_AMessageTheSessionDoesNotHold_WithdrawsNothing() + { + // Arrange + (AgentCoreChatHistoryProvider? provider, Fakes.RecordingConversationStore? store, StubSession? session) = await NewConversation(); + AppendTurn(provider, session, turnIndex: 0, "hello", "hi there"); + + // Act + WithdrawnTurns? withdrawn = await provider.TruncateFromAsync(session, "no-such-message", TestContext.Current.CancellationToken); + await provider.DrainAsync(session); + + // Assert + Assert.Null(withdrawn); + Assert.Equal(["hello", "hi there"], (await ProvideAsync(provider, session)).Select(message => message.Text)); + Assert.Equal(["hello", "hi there"], store.Live(ConversationId).Select(row => row.Content.Text)); + } + + /// + /// An edit under the summary cuts the store itself, so it waits for every write already queued: a cut that + /// overtook a pending append would leave the rows it withdrew to land after it. + /// + [Fact] + public async Task CutUnderSummary_WhileAnAppendIsStillWriting_CutsTheRowsThatAppendWrites() + { + // Arrange + BlockingConversationStore store = new(); + _ = await store.CreateAsync(ConversationId, TestContext.Current.CancellationToken); + AgentCoreChatHistoryProvider provider = new(store); + StubSession session = new(); + _ = provider.BeginConversation(session, ConversationId, []); + AppendTurn(provider, session, turnIndex: 0, "hello", "hi there"); + await provider.DrainAsync(session); + string parent = (await store.ReadAllAsync(ConversationId, TestContext.Current.CancellationToken))[1].MessageId; + store.BlockNextAppend(); + AppendTurn(provider, session, turnIndex: 1, "order 41?", "it ships Friday"); + await store.Entered; + + // Act + ValueTask cut = provider.CutUnderSummaryAsync(session, parent, TestContext.Current.CancellationToken); + store.Release(); + _ = await cut; + await provider.DrainAsync(session); + + // Assert + Assert.Equal( + ["hello", "hi there"], + (await store.ReadAllAsync(ConversationId, TestContext.Current.CancellationToken)).Select(row => row.Content.Text)); + Assert.Equal(["hello", "hi there"], (await ProvideAsync(provider, session)).Select(message => message.Text)); + } + [Fact] public async Task InMemoryStore_AfterTurnAndBargeIn_HoldsTheHeardTextInOrder() { @@ -171,7 +202,7 @@ public async Task InMemoryStore_AfterTurnAndBargeIn_HoldsTheHeardTextInOrder() AppendTurn(provider, session, turnIndex: 0, "order 41?", "it ships Friday from the depot"); // Act - _ = provider.TruncateLastReply(session, "it ships", TimeSpan.FromMilliseconds(200)); + _ = provider.RewriteReply(session, 0, "it ships"); // Assert await provider.DrainAsync(session); diff --git a/tests/AgentCore.Application.Tests/Transcript/CallerNameFillChatClient.cs b/tests/AgentCore.Application.Tests/Transcript/CallerNameFillChatClient.cs new file mode 100644 index 00000000..ea6608b7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/CallerNameFillChatClient.cs @@ -0,0 +1,48 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// The extractor's model: fills callerName from the last user line that says "I am ...", and leaves it + /// unset otherwise. + /// + internal sealed class CallerNameFillChatClient : IChatClient + { + private const string Introduction = "I am "; + + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + string? said = messages + .SelectMany(message => message.Text.Split('\n')) + .LastOrDefault(line => line.Contains(Introduction, StringComparison.Ordinal)); + string json = said is null + ? """{ "callerName": null }""" + : $$"""{ "callerName": "{{said[(said.IndexOf(Introduction, StringComparison.Ordinal) + Introduction.Length)..].Trim()}}" }"""; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, json))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ChatResponse response = await GetResponseAsync(messages, options, cancellationToken); + foreach (ChatResponseUpdate update in response.ToChatResponseUpdates()) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs b/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs index 77a8346a..e0d028be 100644 --- a/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs +++ b/tests/AgentCore.Application.Tests/Transcript/CapturingChatClient.cs @@ -1,10 +1,10 @@ +using System.Runtime.CompilerServices; using AgentCore.Application.Tests.Fakes; using Microsoft.Extensions.AI; namespace AgentCore.Application.Tests.Transcript { /// Answers each request in turn, keeps what it was asked, and runs a hook before answering. - /// Buffered path only, like . internal sealed class CapturingChatClient(Func beforeReply, params string[] replies) : IChatClient { private readonly Func _beforeReply = beforeReply; @@ -28,10 +28,16 @@ public async Task GetResponseAsync( return new ChatResponse(new ChatMessage(ChatRole.Assistant, _replies[callIndex])); } - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - throw new NotSupportedException("These facts drive the buffered path only."); + ChatResponse response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + foreach (ChatResponseUpdate update in response.ToChatResponseUpdates()) + { + yield return update; + } } private static string Words(ChatMessage message) diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs new file mode 100644 index 00000000..ab231481 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCatchUpTests.cs @@ -0,0 +1,164 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Fakes; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using Microsoft.Extensions.AI; +using Xunit; +using static AgentCore.Application.Tests.Transcript.ConversationSessionResumeTestSupport; + +namespace AgentCore.Application.Tests.Transcript +{ + /// A long-lived session that catches up on a turn another session of its conversation saved. + public sealed class ConversationSessionCatchUpTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private const string OrderYaml = """ + apiVersion: agentcore/v1 + state: + orderStatus: { type: string, writer: extractor } + turnsInAlpha: + type: integer + default: 0 + writer: counter + increment: { "===": [ { var: stage }, "alpha" ] } + turnsInBeta: + type: integer + default: 0 + writer: counter + increment: { "===": [ { var: stage }, "beta" ] } + guards: + shipped: { "===": [ { var: orderStatus }, "shipped" ] } + agents: + defaults: { clock: false } + items: + - { id: alpha, instructions: "STAGE-ALPHA" } + - { id: beta, instructions: "STAGE-BETA" } + - { id: done, instructions: "STAGE-DONE" } + entries: + main: + policy: + initial: alpha + stages: + - { id: alpha, agent: alpha, to: [ { stage: beta, when: shipped } ] } + - { id: beta, agent: beta } + - { id: closed, agent: done, terminal: true } + """; + + // IConversationStore.AppendAsync refuses a turn index the conversation already saved (owner ruling 2026-09-24, + // "refuse at save"). Session B saved turn 1, so session A, which ran turn 0, must run its next turn as turn 2. + [Fact] + public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_SavesItsNextTurnUnderTheNextIndex() + { + // Arrange + InMemoryConversationStore store = new(); + using ScriptedChatClient replyA = new("a"); + ConversationSession a = CreateSession(OneAgentYaml, replyA, store); + _ = await a.RunTurnAsync("q0", Ct); + await a.FlushTranscriptAsync(); + + using ScriptedChatClient replyB = new("b"); + ConversationSession b = CreateSession(OneAgentYaml, replyB, store, a.ConversationId); + _ = await b.RunTurnAsync("q1", Ct); + await b.FlushTranscriptAsync(); + + // Act + TurnResult third = await a.RunTurnAsync("q2", Ct); + await a.FlushTranscriptAsync(); + + // Assert + Assert.Equal(2, third.TurnIndex); + IReadOnlyList rows = await store.ReadForSessionAsync(a.ConversationId, Ct); + Assert.Equal( + [(0, "q0"), (0, "a"), (1, "q1"), (1, "b"), (2, "q2"), (2, "a")], + rows.Select(row => (row.TurnIndex, row.Content.Text))); + } + + [Fact] + public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RunsInTheStageAndSeesTheSlotsThatTurnStored() + { + // Arrange + InMemoryConversationStore store = new(); + using RequestRecordingChatClient reply = new("a0", "a2"); + ConversationSession a = CreateSession(OrderYaml, reply, store); + _ = await a.RunTurnAsync("q0", Ct); + await a.FlushTranscriptAsync(); + await SaveAnotherSessionsTurnAsync(store, a.ConversationId, new ConversationSessionState + { + NextTurnIndex = 2, + Stage = "beta", + Slots = new Dictionary { ["orderStatus"] = "shipped", ["turnsInAlpha"] = 2L }, + }); + + // Act + TurnResult third = await a.RunTurnAsync("q2", Ct); + await a.FlushTranscriptAsync(); + + // Assert + Assert.Equal((2, "beta"), (third.TurnIndex, third.StageBefore)); + Assert.EndsWith("STAGE-BETA", reply.Instructions[^1], StringComparison.Ordinal); + ConversationSessionState? stored = (await store.GetAsync(a.ConversationId, Ct))?.State; + Assert.Equal(("beta", 3), (stored?.Stage, stored?.NextTurnIndex)); + Assert.Equal( + [("orderStatus", "shipped"), ("turnsInAlpha", "2"), ("turnsInBeta", "1")], + stored!.Slots.OrderBy(slot => slot.Key, StringComparer.Ordinal).Select(slot => (slot.Key, slot.Value?.ToString()))); + } + + // The stored state is the conversation's: a slot only this session holds, say from a turn the store refused, + // is not part of it. + [Fact] + public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_HoldsNoSlotThatTurnDidNotStore() + { + // Arrange + InMemoryConversationStore store = new(); + using ScriptedChatClient reply = new("a"); + ConversationSession a = CreateSession(OrderYaml, reply, store); + _ = await a.RunTurnAsync("q0", Ct); + await a.FlushTranscriptAsync(); + await SaveAnotherSessionsTurnAsync(store, a.ConversationId, new ConversationSessionState { NextTurnIndex = 2, Stage = "alpha" }); + + // Act + _ = await a.RunTurnAsync("q2", Ct); + + // Assert + Assert.Equal(1, a.State.Read("turnsInAlpha")?.GetValue()); + } + + [Fact] + public async Task ASessionThatCaughtUpOnAnotherSessionsTurn_RefusesTheTurnWhenThatTurnEndedTheConversation() + { + // Arrange + InMemoryConversationStore store = new(); + using ScriptedChatClient reply = new("a"); + ConversationSession a = CreateSession(OrderYaml, reply, store); + _ = await a.RunTurnAsync("q0", Ct); + await a.FlushTranscriptAsync(); + await SaveAnotherSessionsTurnAsync( + store, a.ConversationId, new ConversationSessionState { NextTurnIndex = 2, Stage = "closed", IsComplete = true }); + + // Act + Exception? refused = await Record.ExceptionAsync(() => a.RunTurnAsync("q2", Ct)); + + // Assert + Assert.IsType(refused); + IReadOnlyList rows = await store.ReadForSessionAsync(a.ConversationId, Ct); + Assert.Equal(4, rows.Count); + } + + /// Saves turn 1 as another session of the conversation would: its words, with the state after them. + private static async Task SaveAnotherSessionsTurnAsync(InMemoryConversationStore store, string conversationId, ConversationSessionState state) + { + _ = await store.AppendAsync( + conversationId, + [ + new ConversationMessageDraft(1, new ChatMessage(ChatRole.User, "q1"), "b-user"), + new ConversationMessageDraft(1, new ChatMessage(ChatRole.Assistant, "b"), "b-reply"), + ], + state, + Ct); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs index 8a109bd3..5f902412 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionCompactionFailureTests.cs @@ -105,7 +105,7 @@ public async Task CompactAsync_WordsMoveWhileTheSummariserRuns_TheSummaryIsRefus ConversationSession? session = null; BargingCompactionStrategy strategy = new( new SummarizationCompactionStrategy(new ScriptedChatClient("the gist of it"), CompactionTriggers.Always, minimumPreservedGroups: 0), - () => session!.Compiled.History.AppendTurn(session.AgentSession!, [new ChatMessage(ChatRole.User, "barged in")])); + () => _ = session!.Compiled.History.CommitTurn(session.AgentSession!, new TurnCommit(new ChatMessage(ChatRole.User, "barged in")))); session = CreateSession(new ScriptedChatClient("a3"), store, conversationId: "c1", compaction: SummaryOnly(strategy)); TurnResult result = await session.RunTurnAsync("q3", TestContext.Current.CancellationToken); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs index df99372c..3f65fef0 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionGraphTranscriptTests.cs @@ -89,7 +89,7 @@ public async Task Run_GraphRow_SecondTurn_ModelSeesNoIntermediateNodeReply() second, message => message.Role == ChatRole.System && message.Text.Contains( - TurnMessages.HistoryPreamble + TurnMessages.CallerLinePrefix + "where is my order", + TurnMessages.HistoryPreamble + TurnMessages.UserLinePrefix + "where is my order", StringComparison.Ordinal) && message.Text.Contains(TurnMessages.AgentLinePrefix + Spoken, StringComparison.Ordinal)); Assert.DoesNotContain(second, message => message.Text.Contains(Thinking, StringComparison.Ordinal)); @@ -133,7 +133,7 @@ public async Task Interrupt_MidGraphReply_StoresTheHeardWordsOnly() await spoke; // Act - bool recorded = session.Interrupt("Order 41", TimeSpan.FromMilliseconds(300)); + bool recorded = session.Cut(0, new TurnCut("Order 41", TimeSpan.FromMilliseconds(300))); // Assert Assert.True(recorded); @@ -161,7 +161,7 @@ public async Task Interrupt_BeforeAnyGraphWordLanded_StoresTheCallerUtteranceAlo await spoke; // Act - bool recorded = session.Interrupt(string.Empty, TimeSpan.FromMilliseconds(40)); + bool recorded = session.Cut(0, new TurnCut(string.Empty, TimeSpan.FromMilliseconds(40))); // Assert Assert.True(recorded); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs new file mode 100644 index 00000000..2796a4ba --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionRefusedCatchUpTests.cs @@ -0,0 +1,114 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Tests.Runtime; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// A long-lived session whose turn the store refused, because another session saved that turn first: its next + /// turn runs on what the store holds, not on the words and state of the turn it lost. + /// + public sealed class ConversationSessionRefusedCatchUpTests + { + private const string ConversationId = "c-refused"; + + /// The event id of Log.TurnRunFaulted. + private const int TurnRunFaultedEvent = 28; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 30_000)] + public async Task AfterARefusedTurn_TheNextTurnRunsOnTheStoredWordsAndState() + { + // Arrange: the busy mark fails open, so both sessions run turn 0 at once, and B saves it first. + HeldFirstTurnChatClient reply = new("I am Alice"); + InMemoryConversationStore inner = new(); + ConversationSessionFactory factory = Build(reply, new UnmarkableStore(inner)); + ConversationSession a = factory.Create(ConversationId); + ConversationSession b = factory.Create(ConversationId); + + Task alice = a.RunTurnAsync("I am Alice", Ct); + await reply.Held.Task.WaitAsync(Ct); + _ = await b.RunTurnAsync("I am Bob", Ct); + reply.Release(); + Exception? refused = await Record.ExceptionAsync(() => alice); + + // Act + TurnResult next = await a.RunTurnAsync("what is my name", Ct); + await a.FlushTranscriptAsync(); + + // Assert + _ = Assert.IsType(refused); + Assert.Equal(1, next.TurnIndex); + Assert.Equal( + ["I am Bob", "noted", "what is my name"], + reply.LastRequest.Where(message => message.Text.Length > 0 && message.Role != ChatRole.System) + .Select(message => message.Text)); + ConversationRecord? stored = await inner.GetAsync(ConversationId, Ct); + Assert.Equal("\"Bob\"", stored?.State?.Slots["callerName"]?.ToJsonString()); + Assert.Equal(2, stored?.State?.NextTurnIndex); + } + + [Fact(Timeout = 30_000)] + public async Task ARunFaultOnATurnTheStoreRefused_IsLoggedOnce() + { + // Arrange + HeldFirstTurnChatClient reply = new("I am Alice") { FaultsHeldRequest = true }; + using RecordingLoggerFactory logs = new(); + ConversationSessionFactory factory = Build(reply, new UnmarkableStore(new InMemoryConversationStore()), logs.CreateLogger("session")); + ConversationSession a = factory.Create(ConversationId); + ConversationSession b = factory.Create(ConversationId); + + Task alice = a.RunTurnAsync("I am Alice", Ct); + await reply.Held.Task.WaitAsync(Ct); + _ = await b.RunTurnAsync("I am Bob", Ct); + + // Act + reply.Release(); + Exception? refused = await Record.ExceptionAsync(() => alice); + + // Assert + _ = Assert.IsType(refused); + CapturedLine line = Assert.Single(logs.Of(TurnRunFaultedEvent)); + Assert.Equal(LogLevel.Error, line.Level); + _ = Assert.IsType(line.Exception); + } + + private static ConversationSessionFactory Build(HeldFirstTurnChatClient reply, IConversationStore store, ILogger? logger = null) + { + RoutingChatClientFactory clients = new(reply); + _ = clients.Route("fill", new CallerNameFillChatClient()); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(InterruptionSessions.ExtractorYaml), + new AgentCompilationContext(clients) { ConversationStore = store })["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + ConversationSessionFactory.CreateExtractor(compiled, clients), + logger: logger, + observers: ConversationObservers.Standard(new InMemoryAuditSink(), logger)); + } + + /// A store whose busy table cannot be reached, so the busy mark fails open. + private sealed class UnmarkableStore(IConversationStore inner) : DelegatingConversationStore(inner) + { + public override ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("the busy table is unreachable"); + } + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs index 811798cb..6e0d39e3 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionStoreFailureTests.cs @@ -33,6 +33,22 @@ public sealed class ConversationSessionStoreFailureTests agent: only """; + private const string EndsOnTheSecondTurnYaml = """ + apiVersion: agentcore/v1 + guards: + secondTurn: { ">=": [ { var: turnIndex }, 2 ] } + agents: + items: + - { id: only, instructions: "ok" } + entries: + main: + policy: + initial: working + stages: + - { id: working, agent: only, to: [ { stage: done, when: secondTurn } ] } + - { id: done, agent: only, terminal: true } + """; + /// /// The reply.interrupted row names a hash of the words the caller heard. Raised before /// store 1 holds them, it names a hash of words nothing holds. @@ -53,7 +69,7 @@ public async Task Interrupt_MidReply_StoreOneHoldsTheWordsBeforeTheInterruptedFa await spoke; // Act - bool recorded = session.Interrupt("Hello", TimeSpan.FromMilliseconds(300)); + bool recorded = session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(300))); // Assert reply.OpenGate(); @@ -138,6 +154,32 @@ public async Task Append_StoreThrows_RaisesDiagnosticWithNoEventId() Assert.Equal(stored.Length, stored.Select(item => item.EventId).Distinct().Count()); } + /// + /// A turn that ended the conversation ended it, even when the store dropped its words: the next turn is + /// refused, not run on the earlier stage the store still holds, because no row may follow conversation.ended. + /// + [Fact] + public async Task Append_StoreThrowsOnTheTurnThatEndsTheConversation_TheNextTurnIsRefused() + { + // Arrange + using RequestRecordingChatClient reply = new("hi there", "goodbye", "still here?"); + FlakyAppendConversationStore store = new(); + ConversationSession session = CreateSession(EndsOnTheSecondTurnYaml, reply, store); + Assert.False((await session.RunTurnAsync("hello", TestContext.Current.CancellationToken)).IsTerminal); + store.Down = true; + Assert.True((await session.RunTurnAsync("bye", TestContext.Current.CancellationToken)).IsTerminal); + await session.FlushTranscriptAsync(); + store.Down = false; + + // Act + InvalidOperationException refused = await Assert.ThrowsAsync( + () => session.RunTurnAsync("one more", TestContext.Current.CancellationToken)); + + // Assert + Assert.Contains("runs no further turn", refused.Message, StringComparison.Ordinal); + Assert.Equal(2, reply.Requests.Count); + } + private static ConversationSession CreateSession( string yaml, IChatClient reply, IConversationStore store, params IConversationObserver[] observers) { diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs index 0c802ca8..2c0a4fdd 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationSessionTranscriptTests.cs @@ -86,7 +86,7 @@ public async Task Interrupt_MidReply_StoredTranscriptHoldsHeardTextOnly() await spoke; // Act - bool recorded = session.Interrupt("Hello", TimeSpan.FromMilliseconds(300)); + bool recorded = session.Cut(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(300))); // Assert reply.OpenGate(); @@ -112,7 +112,7 @@ public async Task InterruptAfterTheTurnEnded_ToolTurnWithProse_StoresTheHeardWor await DrainAsync(session.RunTurnStreamingAsync("how much?", TestContext.Current.CancellationToken)); // Act - bool recorded = session.Interrupt("the price", TimeSpan.FromMilliseconds(400)); + bool recorded = session.Cut(0, new TurnCut("the price", TimeSpan.FromMilliseconds(400))); // Assert Assert.True(recorded); @@ -133,7 +133,7 @@ public async Task InterruptAfterTheTurnEnded_ToolTurnWithProse_StoresTheHeardWor /// /// Step 1's second failure mode: a cut that reached back a turn would replace a sentence the /// caller heard in full, and nothing would detect it. The guard is ConversationSession's, so this - /// drives it through rather than through the provider. + /// drives it through rather than through the provider. /// [Fact] public async Task Interrupt_AfterASecondTurn_LeavesTheFirstTurnsReplyWhole() @@ -146,7 +146,7 @@ public async Task Interrupt_AfterASecondTurn_LeavesTheFirstTurnsReplyWhole() _ = await session.RunTurnAsync("order 41?", TestContext.Current.CancellationToken); // Act - bool recorded = session.Interrupt("it ships", TimeSpan.FromMilliseconds(500)); + bool recorded = session.Cut(1, new TurnCut("it ships", TimeSpan.FromMilliseconds(500))); // Assert Assert.True(recorded); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptCompactionTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptCompactionTests.cs index 658ae538..7956425b 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptCompactionTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptCompactionTests.cs @@ -157,7 +157,7 @@ public void Resync_WithTheSummaryAndTheRowsAboveIt_BuildsTheSameView() } [Fact] - public void TruncateLastReply_NeverRewritesTheSummary() + public void RewriteReply_NeverRewritesTheSummary() { // The summary is an assistant message with text, in the same turn as the reply. A barge-in // must cut the reply and leave the summary whole. @@ -166,7 +166,7 @@ public void TruncateLastReply_NeverRewritesTheSummary() _ = transcript.Compact(Assistant("past"), coversUpTo: 3, transcript.Revision); _ = transcript.Append([User("q5"), Assistant("a5 in full")]); - IReadOnlyList rows = transcript.TruncateLastReply("a5"); + IReadOnlyList rows = transcript.RewriteReply("a5").Rewritten; Assert.Equal("a5", Assert.Single(rows).Content.Text); Assert.Equal("past", transcript.Summary?.Message.Text); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs index a446982e..0558f218 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptEditTests.cs @@ -108,10 +108,10 @@ public void Resume_SetsTheReplyABargeInWouldCut() } [Fact] - public void Resume_ThenTruncateLastReply_ActuallyCuts() + public void Resume_ThenRewriteReply_ActuallyCuts() { // A resumed conversation has to know which reply a barge-in aims at. With no ordinal to aim at, - // TruncateLastReply returns an empty list, which the provider reports as a write it declined + // RewriteReply returns an empty list, which the provider reports as a write it declined // rather than as a cut it lost — so the caller is silently not heard. ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; _ = transcript.Resume( @@ -121,7 +121,7 @@ public void Resume_ThenTruncateLastReply_ActuallyCuts() ], new TranscriptMarks(NextOrdinal: 2, NextTurnIndex: 1)); - IReadOnlyList rows = transcript.TruncateLastReply("it ships"); + IReadOnlyList rows = transcript.RewriteReply("it ships").Rewritten; _ = Assert.Single(rows); Assert.Equal("it ships", rows[0].Content.Text); diff --git a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs index 6dbb6b82..d7f1f430 100644 --- a/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs +++ b/tests/AgentCore.Application.Tests/Transcript/ConversationTranscriptTests.cs @@ -77,7 +77,7 @@ public void Append_ToolCallingTurn_AimsTheCutAtTheSpokenReply() } [Fact] - public void TruncateLastReply_ReplyCarryingUsage_KeepsEverythingButTheWords() + public void RewriteReply_ReplyCarryingUsage_KeepsEverythingButTheWords() { // Arrange ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; @@ -87,7 +87,7 @@ public void TruncateLastReply_ReplyCarryingUsage_KeepsEverythingButTheWords() _ = transcript.Append([User("order 41?"), reply]); // Act - IReadOnlyList rows = transcript.TruncateLastReply("it ships"); + IReadOnlyList rows = transcript.RewriteReply("it ships").Rewritten; // Assert ConversationMessage row = Assert.Single(rows); @@ -96,26 +96,27 @@ public void TruncateLastReply_ReplyCarryingUsage_KeepsEverythingButTheWords() } [Fact] - public void TruncateLastReply_TheConversationSpokeNothing_RewritesNoRow() + public void RewriteReply_TheConversationSpokeNothing_RewritesNoRow() { // Arrange ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; _ = transcript.Append([User("hello")]); // Act - IReadOnlyList rows = transcript.TruncateLastReply("nothing was said"); + IReadOnlyList rows = transcript.RewriteReply("nothing was said").Rewritten; // Assert Assert.Empty(rows); } /// - /// A model routinely writes a line and puts the tool call it announces on the same message. The - /// caller heard as much of the turn as the vendor played and nothing else, so the announcement - /// must not survive the cut as words the caller is recorded as having heard. + /// A model routinely writes a line and puts the tool call it announces on the same message, then + /// answers in a second step. The caller heard the first step whole and part of the second, so the + /// first step's words stay before their tool call and only the second step is cut (owner ruling + /// 2026-09-23; LiveKit keeps each step's forwarded_text as its own message). /// [Fact] - public void TruncateLastReply_ProseBesideAToolCall_KeepsTheConversationAndDropsTheWords() + public void RewriteReply_ProseBesideAToolCall_KeepsTheProseInPlaceAndCutsTheLastStep() { // Arrange ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; @@ -131,12 +132,70 @@ public void TruncateLastReply_ProseBesideAToolCall_KeepsTheConversationAndDropsT ]); // Act - IReadOnlyList rows = transcript.TruncateLastReply("the price"); + IReadOnlyList rows = transcript.RewriteReply("Let me check that for youthe price").Rewritten; // Assert - Assert.Equal([1, 3], rows.Select(row => row.Ordinal)); - Assert.Equal([string.Empty, "the price"], rows.Select(row => row.Content.Text)); - _ = Assert.Single(rows[0].Content.Contents.OfType()); + ConversationMessage row = Assert.Single(rows); + Assert.Equal(3, row.Ordinal); + Assert.Equal("the price", row.Content.Text); + Assert.Equal( + ["how much?", "Let me check that for you", string.Empty, "the price"], + transcript.Messages.Select(stored => stored.Message.Text)); + _ = Assert.Single(transcript.Messages[1].Message.Contents.OfType()); + } + + /// + /// The caller barged in on the first step's own audio, after its tool ran but before the second step + /// spoke. The first step is cut to what was heard, and the second step, left carrying nothing, is removed + /// rather than kept as an empty assistant message (LiveKit adds a message only if forwarded_text:, + /// agent_activity.py:3335). + /// + [Fact] + public void RewriteReply_HeardEndsInsideTheFirstStep_CutsTheFirstStepAndRemovesTheSecond() + { + // Arrange + ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; + _ = transcript.Append( + [ + User("how much?"), + new ChatMessage( + ChatRole.Assistant, + [new TextContent("Let me check that for you"), new FunctionCallContent("conversation-1", "lookup")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("conversation-1", "50")]), + Assistant("the price is fifty"), + ]); + + // Act + ReplyRewrite rewrite = transcript.RewriteReply("Let me check"); + + // Assert + ConversationMessage row = Assert.Single(rewrite.Rewritten); + Assert.Equal(1, row.Ordinal); + Assert.Equal("Let me check", row.Content.Text); + _ = Assert.Single(row.Content.Contents.OfType()); + Assert.Equal(3, Assert.Single(rewrite.Removed).Ordinal); + Assert.Equal([0, 1, 2], transcript.Messages.Select(stored => stored.Ordinal)); + } + + /// + /// A caller who heard nothing of a one-message reply leaves that message carrying nothing, so it goes, and + /// the model is never read an assistant message with no content. + /// + [Fact] + public void RewriteReply_NothingWasHeard_RemovesTheReplyAndReadsOnlyTheQuestion() + { + // Arrange + ConversationTranscript transcript = new() { ConversationId = "conversation-1" }; + _ = transcript.Append([User("hi"), Assistant("Hello there.")]); + + // Act + ReplyRewrite rewrite = transcript.RewriteReply(string.Empty); + + // Assert + Assert.Empty(rewrite.Rewritten); + Assert.Equal(1, Assert.Single(rewrite.Removed).Ordinal); + Assert.Equal(["hi"], transcript.Read().Select(message => message.Text)); + Assert.Null(transcript.LastAssistantOrdinal); } /// @@ -157,7 +216,7 @@ public void BeginTurn_NextTurnOpens_LeavesThePreviousReplyOpenToACut() // Assert Assert.Equal(1, transcript.LastAssistantOrdinal); - ConversationMessage row = Assert.Single(transcript.TruncateLastReply("hi")); + ConversationMessage row = Assert.Single(transcript.RewriteReply("hi").Rewritten); Assert.Equal(0, row.TurnIndex); } @@ -169,7 +228,7 @@ public void Read_AfterTruncate_ReturnsHeardTextNotProducedText() _ = transcript.Append([User("order 41?"), Assistant("it ships Friday from the depot")]); // Act - _ = transcript.TruncateLastReply("it ships Fri"); + _ = transcript.RewriteReply("it ships Fri"); // Assert Assert.Equal(["order 41?", "it ships Fri"], transcript.Read().Select(message => message.Text)); diff --git a/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs b/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs new file mode 100644 index 00000000..8fd408d7 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/HeldFirstTurnChatClient.cs @@ -0,0 +1,77 @@ +using System.Runtime.CompilerServices; +using AgentCore.Application.Tests.Runtime; +using Microsoft.Extensions.AI; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// Answers noted to every request, and holds the first request that carries until + /// the test releases it, so another session can save the same turn first. With , the + /// held request ends in a transport fault instead of an answer. + /// + internal sealed class HeldFirstTurnChatClient(string heldWords) : IChatClient + { + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the words whose first request is held. + public string HeldWords { get; } = heldWords; + + /// Completes once the held request arrived. + public TaskCompletionSource Held { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a value indicating whether the held request, once released, throws instead of answering. + public bool FaultsHeldRequest { get; init; } + + /// Gets the last request the model was sent. + public IReadOnlyList LastRequest { get; private set; } = []; + + /// Lets the held request answer. + public void Release() + { + _ = _gate.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List request = [.. messages]; + LastRequest = request; + if (!_gate.Task.IsCompleted && request.Any(message => message.Text == HeldWords)) + { + _ = Held.TrySetResult(); + await _gate.Task.WaitAsync(cancellationToken); + if (FaultsHeldRequest) + { + throw new HttpRequestException(DownModelChatClient.Message); + } + } + + string id = Guid.NewGuid().ToString("N"); + yield return new ChatResponseUpdate(ChatRole.Assistant, "noted") { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/ShownWordsTests.cs b/tests/AgentCore.Application.Tests/Transcript/ShownWordsTests.cs new file mode 100644 index 00000000..fa2150e8 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/ShownWordsTests.cs @@ -0,0 +1,23 @@ +using AgentCore.Application.Transcript; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Transcript +{ + /// The shown text laid back over the messages of one reply, by the rule documents. + public sealed class ShownWordsTests + { + [Fact] + public void Lay_ShownRunsPastTheLastMessage_TheLastMessageWithTextTakesTheRest() + { + ChatMessage stepOne = new(ChatRole.Assistant, [new TextContent("Let me check."), new FunctionCallContent("call_1", "look_it_up")]); + ChatMessage result = new(ChatRole.Tool, [new FunctionResultContent("call_1", "42")]); + ChatMessage stepTwo = new(ChatRole.Assistant, "Your order"); + + List laid = ShownWords.Lay([stepOne, result, stepTwo], "Let me check.Your order ships today"); + + Assert.Equal(["Let me check.", string.Empty, "Your order ships today"], laid.Select(message => message.Text)); + Assert.Same(stepOne, laid[0]); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs b/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs new file mode 100644 index 00000000..19f38008 --- /dev/null +++ b/tests/AgentCore.Application.Tests/Transcript/TurnWordsTests.cs @@ -0,0 +1,169 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Application.Tests.Transcript +{ + /// + /// The words a cut or failed turn of two steps writes: the user's message, step one's words before its tool + /// call, the finished pair, then only the shown part of step two, or the fallback line (design section 3; owner + /// rulings 2026-09-23). + /// + public sealed class TurnWordsTests + { + private static readonly ChatMessage User = new(ChatRole.User, "first question"); + + private static readonly ChatMessage StepOne = new( + ChatRole.Assistant, + [new TextContent("Let me check."), new FunctionCallContent("call_1", "look_it_up")]); + + private static readonly ChatMessage Result = new(ChatRole.Tool, [new FunctionResultContent("call_1", "42")]); + + private const string Fallback = "Sorry, the order service did not answer."; + + // Probe T2_TextStreamAbortInSecondStep_KeepsAllYieldedText. + [Fact] + public void Compose_StoppedInStepTwo_KeepsEveryYieldedWordInItsStep() + { + List words = TurnWords.Compose( + Cut(new TurnCut(ShownText: null, Played: null), StepOne, Result, new ChatMessage(ChatRole.Assistant, "Your order")), + staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty, "Your order"], words.Select(message => message.Text)); + _ = Assert.Single(words[1].Contents.OfType()); + _ = Assert.Single(words[2].Contents.OfType()); + } + + // Probe T3_TextStreamAbortAfterToolResultBeforeStepTwoText_KeepsStepOneText. + [Fact] + public void Compose_StoppedAfterTheToolResultBeforeStepTwoSpoke_KeepsStepOne() + { + List words = TurnWords.Compose(Cut(new TurnCut(ShownText: null, Played: null), StepOne, Result), staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty], words.Select(message => message.Text)); + _ = Assert.Single(words[1].Contents.OfType()); + } + + // Probe V5_BargeInInSecondStep_KeepsBothStepsHeardText. + [Fact] + public void Compose_HeardEndsInsideStepTwo_KeepsStepOneAndCutsStepTwo() + { + List words = TurnWords.Compose( + Cut(new TurnCut("Let me check.Your order", TimeSpan.FromMilliseconds(500)), StepOne, Result, new ChatMessage(ChatRole.Assistant, "Your order ships today")), + staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty, "Your order"], words.Select(message => message.Text)); + } + + [Fact] + public void Compose_HeardEndsInsideStepOne_CutsStepOneAndDropsStepTwosWords() + { + List words = TurnWords.Compose( + Cut(new TurnCut("Let me", TimeSpan.FromMilliseconds(300)), StepOne, Result, new ChatMessage(ChatRole.Assistant, "Your order")), + staged: []); + + Assert.Equal(["first question", "Let me", string.Empty], words.Select(message => message.Text)); + _ = Assert.Single(words[1].Contents.OfType()); + } + + [Fact] + public void Compose_StepTwoCallStillRunning_DropsThatCallAndKeepsItsWordsAsShown() + { + ChatMessage stepTwo = new( + ChatRole.Assistant, + [new TextContent("One more look."), new FunctionCallContent("call_2", "look_it_up")]); + + List words = TurnWords.Compose( + Cut(new TurnCut("Let me check.One more look.", TimeSpan.FromMilliseconds(900)), StepOne, Result, stepTwo), + staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty, "One more look."], words.Select(message => message.Text)); + Assert.Empty(words[3].Contents.OfType()); + } + + [Fact] + public void Compose_NothingShown_KeepsOnlyTheFinishedPair() + { + List words = TurnWords.Compose(Cut(new TurnCut(string.Empty, Played: null), StepOne, Result), staged: []); + + Assert.Equal(["first question", string.Empty, string.Empty], words.Select(message => message.Text)); + _ = Assert.Single(words[1].Contents.OfType()); + } + + [Fact] + public void Compose_HeardEndsWithASpace_StoresItTrimmed() + { + List words = TurnWords.Compose( + Cut(new TurnCut("Hello ", Played: null), new ChatMessage(ChatRole.Assistant, "Hello there")), + staged: []); + + Assert.Equal(["first question", "Hello"], words.Select(message => message.Text)); + } + + [Fact] + public void Compose_ToolFaultInStepTwo_KeepsStepOneThePairAndTheFallbackInOrder() + { + List words = TurnWords.Compose( + Failed(StepOne, Result, new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_2", "look_it_up")]), new ChatMessage(ChatRole.Assistant, Fallback)), + staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty, Fallback], words.Select(message => message.Text)); + _ = Assert.Single(words[1].Contents.OfType()); + _ = Assert.Single(words[2].Contents.OfType()); + Assert.Equal(ChatRole.Assistant, words[3].Role); + } + + [Fact] + public void Compose_FallbackStreamedIntoStepTwosMessage_StillWritesItOnceAsItsOwnMessage() + { + ChatMessage stepTwo = new( + ChatRole.Assistant, + [new FunctionCallContent("call_2", "look_it_up"), new TextContent(Fallback)]); + + List words = TurnWords.Compose(Failed(StepOne, Result, stepTwo), staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty, Fallback], words.Select(message => message.Text)); + } + + [Fact] + public void Compose_ToolFaultInStepOneWithNoWords_KeepsOnlyTheFallback() + { + List words = TurnWords.Compose( + Failed(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_1", "look_it_up")]), new ChatMessage(ChatRole.Assistant, Fallback)), + staged: []); + + Assert.Equal(["first question", Fallback], words.Select(message => message.Text)); + } + + [Fact] + public void Compose_EmptyReplyWhoseOnlyWordsAreTheFallback_WritesTheFallbackOnce() + { + List words = TurnWords.Compose(Failed(new ChatMessage(ChatRole.Assistant, Fallback)), staged: []); + + Assert.Equal(["first question", Fallback], words.Select(message => message.Text)); + } + + [Fact] + public void Compose_FailedWithNoFallbackLine_AddsNoEmptyMessage() + { + List words = TurnWords.Compose( + new TurnCommit(User) { Seen = new AgentResponse([StepOne, Result]), Reply = string.Empty, Completed = false }, + staged: []); + + Assert.Equal(["first question", "Let me check.", string.Empty], words.Select(message => message.Text)); + _ = Assert.Single(words[2].Contents.OfType()); + } + + private static TurnCommit Failed(params ChatMessage[] seen) + { + return new TurnCommit(User) { Seen = new AgentResponse([.. seen]), Reply = Fallback, Completed = false }; + } + + private static TurnCommit Cut(TurnCut cut, params ChatMessage[] seen) + { + return new TurnCommit(User) { Seen = new AgentResponse([.. seen]), Cut = cut, Completed = false }; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationChannelShapeTests.cs b/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationChannelShapeTests.cs deleted file mode 100644 index 3a0aa316..00000000 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationChannelShapeTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using AgentCore.Application.Conversation; -using AgentCore.AspNetCore.Conversation; -using AgentCore.AspNetCore.Tests.Fakes; -using Xunit; - -namespace AgentCore.AspNetCore.Tests.Conversation -{ - /// - /// The arbiter serves a split channel exactly as it serves a bundled one. - /// - /// - /// If this file ever needs a Telnyx type to compile, D8 has failed and the seam is in the wrong - /// place. That is the whole assertion. - /// - public sealed class ConversationChannelShapeTests - { - [Fact(Timeout = 30_000)] - public async Task ASplitChannelOfTwoObjects_RunsAScriptedConversationThroughTheSameArbiter() - { - ScriptedConversationPort session = new(); - FakeConversationOutput output = new(); - FakeConversationInput input = new(); - ConversationChannel channel = new(input, output); - - ConversationTurnArbiter arbiter = new( - session, - channel.Output, - new ConnectionTaskObserver(() => session.ConversationId, (_, _) => { }, (_, _, _) => { }, (_, _, _) => false), - _ => { }, - _ => { }, - TestContext.Current.CancellationToken); - - // The scripted conversation holds every turn on a gate until a test opens it, so this turn is let - // through before it starts: this test is about the shape of the channel, and never about - // the held-prompt window the arbiter's own tests use that gate for. - session.ReleaseTurn(); - - await arbiter.StartTurnAsync("hello"); - await arbiter.CurrentTurn; - - Assert.NotEmpty(output.Spoken); - Assert.Equal(1, output.Completions); - - await channel.DisposeAsync(); - Assert.Equal(1, input.Disposals); - Assert.Equal(1, output.Disposals); - } - } -} diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs b/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs index ca58a70a..a69485b6 100644 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRegistrationTests.cs @@ -1,10 +1,10 @@ using AgentCore.TestSupport; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Ports; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Xunit; diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationTurnArbiterTests.cs b/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationTurnArbiterTests.cs deleted file mode 100644 index 70776cca..00000000 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationTurnArbiterTests.cs +++ /dev/null @@ -1,321 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text.Json; -using AgentCore.Application.Ports; -using AgentCore.AspNetCore.Conversation; -using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.Domain; -using AgentCore.Domain.Knowledge; -using Microsoft.Extensions.AI; -using Xunit; - -namespace AgentCore.AspNetCore.Tests.Conversation -{ - /// - /// The three questions the arbiter answers, with no socket and no vendor frame in sight. - /// - public sealed class ConversationTurnArbiterTests - { - [Fact(Timeout = 30_000)] - public async Task ASecondUtteranceDuringATurn_IsHeldAndRunsWhenTheTurnEnds() - { - // One is held. The turn in flight finishes, and the held one runs inside its own ending. - ScriptedConversationPort session = new(); - FakeConversationOutput output = new(); - ConversationTurnArbiter arbiter = NewArbiter(session, output); - - Task first = arbiter.StartTurnAsync("one"); - await arbiter.StartTurnAsync("two"); - - session.ReleaseTurn(); - await first; - await arbiter.CurrentTurn; - - Assert.Equal(["one", "two"], session.TurnsRun); - } - - [Fact(Timeout = 30_000)] - public async Task APendingApprovalTurn_SpeaksTheNoticeInsteadOfHangingInSilence() - { - // A voice conversation has no surface to answer an approval on, so the pending turn speaks - // nothing. The arbiter says so instead of closing the reply in silence. - ScriptedConversationPort session = new(); - FakeConversationOutput output = new(); - ConversationTurnArbiter arbiter = NewArbiter(session, output); - session.ReleaseTurn(); - - using JsonDocument empty = JsonDocument.Parse("{}"); - session.LastTurn = new TurnResult("conversation-scripted", 0, string.Empty, string.Empty, string.Empty, false, null) - { - Approvals = [new PendingApproval("req-1", "send_email", empty.RootElement.Clone())], - }; - - await arbiter.StartTurnAsync("one"); - await arbiter.CurrentTurn; - - Assert.Contains(ConversationTurnArbiter.PendingApprovalNotice, output.Spoken); - } - - [Fact(Timeout = 30_000)] - public async Task AThirdUtteranceDuringATurn_IsDroppedRatherThanQueued() - { - // A queue of held prompts would answer questions the caller has already moved past. - ScriptedConversationPort session = new(); - FakeConversationOutput output = new(); - ConversationTurnArbiter arbiter = NewArbiter(session, output); - - Task first = arbiter.StartTurnAsync("one"); - await arbiter.StartTurnAsync("two"); - await arbiter.StartTurnAsync("three"); - - session.ReleaseTurn(); - await first; - await arbiter.CurrentTurn; - - Assert.Equal(["one", "two"], session.TurnsRun); - } - - [Fact(Timeout = 30_000)] - public async Task ABargeInDuringTheHeldPromptWindow_CutsTheTurnTheCallerWasHearing() - { - // Turn one finished streaming and turn two started inside its ending, but the caller is - // still hearing turn one. The record belongs to turn one, and turn two must keep speaking. - ScriptedConversationPort session = new(); - FakeConversationOutput output = new(); - ConversationTurnArbiter arbiter = NewArbiter(session, output); - - // Opened before the first turn, not during it: this test needs turn one to finish streaming - // on its own, and nothing here ever comes back to open a gate the two tests above open while - // a turn is deliberately held in flight. - session.ReleaseTurn(); - - await arbiter.StartTurnAsync("one"); - await arbiter.CurrentTurn; - - bool recorded = arbiter.Interrupt("half a reply", TimeSpan.FromMilliseconds(600)); - - Assert.True(recorded); - Assert.Equal("half a reply", session.LastHeardText); - Assert.Equal(1, output.Stops); - - // The turn the caller heard is the turn running now, so the barge-in cuts it. The other - // half of that comparison — the held-prompt window this test's name describes, where the - // two are different turns — is pinned by the test below. - Assert.True(session.LastCutRunningTurn); - } - - [Fact(Timeout = 30_000)] - public async Task ABargeInWhileTheHeldTurnHasSaidNothing_BelongsToTheTurnTheCallerWasHearing() - { - // The rule the arbiter is hardest to get right. Turn one finished streaming and turn two - // started inside its ending, but turn two has not produced one word, so the caller is still - // hearing turn one. cutsRunningTurn must answer false there — turn two is not the turn to - // cut, and it must keep speaking — and true once turn two is itself what the caller heard. - ScriptedConversationPort session = new(); - FakeConversationOutput output = new(); - ConversationTurnArbiter arbiter = NewArbiter(session, output); - - Task first = arbiter.StartTurnAsync("one"); - await arbiter.StartTurnAsync("two"); - - // Turn one only. Turn two starts inside turn one's own ending and stops at its own gate, - // having said nothing, which is the window this test exists for. - session.ReleaseTurn(1); - await first; - - Assert.True(arbiter.Interrupt("first", TimeSpan.FromMilliseconds(640))); - Assert.False(session.LastCutRunningTurn); - - // Turn two kept speaking, and closed its own reply: the barge-in belonged to turn one, so - // nothing silenced a turn the caller had not heard. - session.ReleaseTurn(2); - await arbiter.CurrentTurn; - - Assert.Contains("two", output.Spoken); - Assert.Equal(2, output.Completions); - - // And now the turn the caller has heard is the turn running now, so the same comparison - // answers the other way. - Assert.True(arbiter.Interrupt("second", TimeSpan.FromMilliseconds(120))); - Assert.True(session.LastCutRunningTurn); - } - - [Fact(Timeout = 30_000)] - public async Task ASecondConversationNamedMidTurn_StillHoldsTheNextUtteranceBehindTheTurnInFlight() - { - // One transport runs one turn at a time, and a vendor that names the conversation a second time - // does not get to start a second reply alongside the first. The prompt that arrives after - // the rename is held, and it runs against the conversation named last. - ScriptedConversationPort replaced = new(); - ScriptedConversationPort named = new(); - FakeConversationOutput output = new(); - ConversationTurnArbiter arbiter = NewArbiter(replaced, output); - - Task running = arbiter.StartTurnAsync("one"); - - arbiter.Rebind(named); - await arbiter.StartTurnAsync("two"); - - Assert.Empty(named.TurnsRun); - - replaced.ReleaseTurn(); - named.ReleaseTurn(); - await running; - await arbiter.CurrentTurn; - - Assert.Equal(["one"], replaced.TurnsRun); - Assert.Equal(["two"], named.TurnsRun); - } - - private static ConversationTurnArbiter NewArbiter(ScriptedConversationPort session, FakeConversationOutput output) - { - return new( - session, - output, - new ConnectionTaskObserver(() => session.ConversationId, (_, _) => { }, (_, _, _) => { }, (_, _, _) => false), - _ => { }, - _ => { }, - CancellationToken.None); - } - } - - /// - /// One call the test drives by hand: it records the turns it was asked to run, holds each of them - /// on one gate the test opens, and remembers what a barge-in told it. - /// - /// - /// The gate is what makes the two holding tests deterministic. Without it a turn would run to its - /// end inside StartTurnAsync itself, and a second utterance would find no turn in flight to - /// be held behind — which is the one condition those tests exist to create. - /// - internal sealed class ScriptedConversationPort : IConversationPort - { - private readonly Dictionary _gates = []; - private readonly List _turnsRun = []; - private readonly Lock _lock = new(); - private bool _allReleased; - private int _turnsStarted; - - /// - public string ConversationId => "conversation-scripted"; - - /// - public string Stage => string.Empty; - - /// - public bool IsComplete => false; - - /// - public TurnResult? LastTurn { get; set; } - - /// - public KnowledgeScope? Scope { get; set; } - - /// Gets what the caller was told it heard, or null before a barge-in. - public string? LastHeardText { get; private set; } - - /// Gets how much of the reply the caller was told had played, or null before a barge-in. - public TimeSpan? LastPlayedDuration { get; private set; } - - /// Gets whether the last barge-in said it cut the turn running now. - public bool? LastCutRunningTurn { get; private set; } - - /// Gets the text of every turn this port was asked to run, in order. - public IReadOnlyList TurnsRun - { - get { lock (_lock) { return [.. _turnsRun]; } } - } - - /// Opens the gate of every turn, the ones already waiting and every later one. - public void ReleaseTurn() - { - lock (_lock) - { - _allReleased = true; - foreach (TaskCompletionSource gate in _gates.Values) - { - _ = gate.TrySetResult(); - } - } - } - - /// Opens the gate of one turn only, named by the order it started in, from one. - /// The turn to release: 1 is the first turn this port was asked to run. - /// - /// One turn at a time is what the arbiter promises, so releasing turn one alone is how a test - /// reaches the window the arbiter's hardest rule lives in: turn one finished and turn two - /// started inside its ending, still holding its own gate and still having said nothing. - /// - public void ReleaseTurn(int ordinal) - { - lock (_lock) - { - _ = GateFor(ordinal).TrySetResult(); - } - } - - /// - public Task RunTurnAsync(string userInput, CancellationToken cancellationToken = default) - { - throw new NotSupportedException("the arbiter only ever streams a turn."); - } - - /// - public Task RunTurnMessageAsync(ChatMessage userInput, CancellationToken cancellationToken) - { - throw new NotSupportedException("the arbiter only ever streams a turn."); - } - - /// - public async IAsyncEnumerable RunTurnStreamingAsync( - string userInput, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - Task gate; - lock (_lock) - { - _turnsRun.Add(userInput); - gate = _allReleased ? Task.CompletedTask : GateFor(++_turnsStarted).Task; - } - - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - - yield return new ChatResponseUpdate(ChatRole.Assistant, userInput); - yield return new ChatResponseUpdate(ChatRole.Assistant, " done"); - } - - /// - public IAsyncEnumerable RunTurnMessageStreamingAsync( - ChatMessage userInput, - CancellationToken cancellationToken) - { - return RunTurnStreamingAsync(userInput.Text ?? string.Empty, cancellationToken); - } - - /// - public bool Interrupt( - string utteranceUntilInterrupt, - TimeSpan durationUntilInterrupt, - bool cutsRunningTurn = true) - { - LastHeardText = utteranceUntilInterrupt; - LastPlayedDuration = durationUntilInterrupt; - LastCutRunningTurn = cutsRunningTurn; - return true; - } - - /// Finds one turn's gate, creating it if the turn has not reached it yet. - /// The turn, in the order this port was asked to run them, from one. - /// The gate that turn waits on. - /// Callers hold _lock. - private TaskCompletionSource GateFor(int ordinal) - { - if (!_gates.TryGetValue(ordinal, out TaskCompletionSource? gate)) - { - gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _gates[ordinal] = gate; - } - - return gate; - } - } -} diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs index dd35e061..028250e7 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs @@ -450,22 +450,10 @@ public async Task AddAgentCore_RegistersTheInMemorySessionsByDefault() { using StartedHost provider = await BuildAsync(OneAgentYaml); - IConversationSessions sessions = provider.GetRequiredService().ForSessions("main"); + IConversationSessions sessions = provider.GetRequiredService().Sessions; _ = Assert.IsType(sessions); - Assert.Same(sessions, provider.GetRequiredService().ForSessions("main")); - } - - [Fact] - public async Task AddAgentCore_RunsTheIdleSweepForTheDefaultSessions() - { - // Expiry needs something to drive it. Without this the idle timeout never fires and the - // text path holds every conversation a caller walked away from for the life of the process. - using StartedHost provider = await BuildAsync(OneAgentYaml); - - Assert.Contains( - provider.GetServices(), - service => service is ConversationSessionSweeper); + Assert.Same(sessions, provider.GetRequiredService().Sessions); } /// @@ -494,50 +482,51 @@ public async Task AddAgentCore_UsesTheSessionsTheHostBound() CountingConversationSessions mine = new(); using StartedHost provider = await BuildAsync( - OneAgentYaml, options => options.UseConversationSessions((_, _) => mine)); + OneAgentYaml, options => options.UseConversationSessions(_ => mine)); - // A distributed store replaces the default one, and the default steps aside. - Assert.Same(mine, provider.GetRequiredService().ForSessions("main")); + // A distributed owner replaces the default one, and the default steps aside. + Assert.Same(mine, provider.GetRequiredService().Sessions); } [Fact] - public async Task AddAgentCore_OpensOneStorePerEntry() + public async Task AddAgentCore_BuildsOneOwnerOverEveryEntrysFactory() { - // A vendor call id arriving on two entries opens two isolated conversations. That holds only when - // each entry has its own store, so the host's opener runs once per entry and is told which. - List opened = []; + // The owner is opened once for the whole app, not once per entry: the opener sees every entry's + // factory in one call, and every entry reads and writes through the one owner it returns. + List> opened = []; using StartedHost provider = await BuildAsync(TwoEntryYaml, options => options.UseConversationSessions( - (entry, factory) => + factories => { - opened.Add(entry); + opened.Add(factories); return new InMemoryConversationSessions( - factory, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + factories, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); })); IConversationSessionRegistry registry = provider.GetRequiredService(); - Assert.Equal(["main", "other"], opened.Order(StringComparer.Ordinal)); - Assert.NotSame(registry.ForSessions("main"), registry.ForSessions("other")); + IReadOnlyDictionary factories = Assert.Single(opened); + Assert.Equal(["main", "other"], factories.Keys.Order(StringComparer.Ordinal)); + + // One owner, not one per entry: a session "main" opened is seen as held — and "other" refused — + // through the very same registry.Sessions. Two separate owners would let "other" open it too. + _ = await registry.Sessions.GetOrOpenAsync("main", "conversation-one-owner", null, TestContext.Current.CancellationToken); + _ = await Assert.ThrowsAsync( + () => registry.Sessions.GetOrOpenAsync("other", "conversation-one-owner", null, TestContext.Current.CancellationToken).AsTask()); } [Fact] public async Task AddAgentCore_RegistersTheSessionRegistryAsAPublicPort() { - // A consumer reaches an entry's store through the port, never through the internal - // registry, and never through a bare IConversationSessions: no store spans the whole host. + // A consumer reaches the one owner through the port, never through the internal registry, and + // never through a bare IConversationSessions registration of its own. using StartedHost provider = await BuildAsync(OneAgentYaml); IConversationSessionRegistry registry = provider.GetRequiredService(); Assert.Equal(["main"], registry.Entries); - Assert.Same( - provider.GetRequiredService().ForSessions("main"), - registry.ForSessions("main")); + Assert.Same(provider.GetRequiredService().Sessions, registry.Sessions); Assert.Null(provider.GetService()); - - InvalidOperationException failure = Assert.Throws(() => registry.ForSessions("missing")); - Assert.Contains("main", failure.Message, StringComparison.Ordinal); } // ------------------------------------------------------------------------------------------- @@ -2176,17 +2165,17 @@ public ValueTask EvaluateAsync( /// Sessions a host registers in place of the default ones. private sealed class CountingConversationSessions : IConversationSessions { - public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default) + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } - public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { return ValueTask.FromResult(null); } - public ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; } diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AgentCoreOptionsValidationTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AgentCoreOptionsValidationTests.cs new file mode 100644 index 00000000..c5342146 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AgentCoreOptionsValidationTests.cs @@ -0,0 +1,23 @@ +using AgentCore.AspNetCore.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; +using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; + +namespace AgentCore.AspNetCore.Tests.DependencyInjection +{ + /// + /// validated eagerly on host.StartAsync, before + /// AgentCoreBootService opens a single adapter. + /// + public sealed class AgentCoreOptionsValidationTests + { + [Fact] + public async Task ANegativeResponseRetention_FailsTheHostStart() + { + OptionsValidationException failure = await Assert.ThrowsAsync( + () => BuildAsync(OneAgentYaml, options => options.ResponseRetention = TimeSpan.FromSeconds(-1))); + + Assert.Contains("ResponseRetention", failure.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs new file mode 100644 index 00000000..cbb8750c --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/DefaultSessionExpiryTests.cs @@ -0,0 +1,51 @@ +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; + +namespace AgentCore.AspNetCore.Tests.DependencyInjection +{ + /// The idle timeout of the sessions a host gets when it binds none of its own. + public sealed class DefaultSessionExpiryTests + { + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task TheDefaultSessionsUnloadAConversationLeftIdleOnTheHostClock() + { + // Without this the text path holds every conversation a caller walked away from for the life of + // the process. A caller who simply stops replying reaches no terminal stage, so the idle timer is + // the only thing that ever frees the session; the conversation itself stays open. + FakeTimeProvider clock = new(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); + using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.TimeProvider = clock); + InMemoryConversationSessions sessions = Assert.IsType( + provider.GetRequiredService().Sessions); + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + clock.Advance(InMemoryConversationSessions.DefaultIdleTimeout - TimeSpan.FromSeconds(1)); + Assert.Equal(1, sessions.Count); + + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(0, sessions.Count); + } + + [Fact] + public async Task StoppingTheHostStopsTheIdleTimers() + { + FakeTimeProvider clock = new(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); + StartedHost provider = await BuildAsync(OneAgentYaml, options => options.TimeProvider = clock); + IConversationSessions sessions = provider.GetRequiredService().Sessions; + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, Token); + + provider.Dispose(); + clock.Advance(InMemoryConversationSessions.DefaultIdleTimeout); + + Assert.Same(session, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Token)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs new file mode 100644 index 00000000..9bc28a9a --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/FillerKeyCheckTests.cs @@ -0,0 +1,53 @@ +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.DependencyInjection +{ + /// + /// Owner ruling 2026-09-23: a filler key that names no served tool is not refused; startup logs one + /// warning with the pointer to the key. + /// + public sealed class FillerKeyCheckTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: look_it_up, kind: binding, binds: lookup } + agents: + items: + - { id: solo, instructions: "answer", tools: [ look_it_up ] } + entries: + main: + agent: solo + providers: + conversation: + kind: telnyx-relay + filler: + look_it_up: { say: "One moment", delaySeconds: 1 } + look_it_upp: { say: "One moment", delaySeconds: 1 } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + """; + + private static readonly HashSet Served = new(StringComparer.Ordinal) { "look_it_up" }; + + [Fact] + public void AnUnknownKeyLogsOneWarningWithItsPointer_AndAKnownKeyLogsNone() + { + using RecordingLoggerFactory logs = new(); + + FillerKeyCheck.Warn(ConfigurationLoader.LoadYaml(Yaml), Served, logs.CreateLogger("boot")); + + CapturedLine line = Assert.Single(logs.Lines); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.Equal("/providers/conversation/filler/look_it_upp", line.Field("Pointer")); + Assert.Equal("look_it_upp", line.Field("ToolId")); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs index 28d695d9..24065b66 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/WorkspaceStartupTests.cs @@ -3,6 +3,7 @@ using AgentCore.Application.Runtime; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.TestSupport; using Microsoft.Extensions.DependencyInjection; using Xunit; using static AgentCore.AspNetCore.Tests.DependencyInjection.StartedHostFixture; @@ -31,8 +32,8 @@ public async Task AHostBoundToAWorkspaceRoot_CreatesTheConversationsFolderWhenAC { using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.UseWorkspace(_tempRoot)); - IConversationSessions sessions = provider.GetRequiredService().ForSessions("main"); - ConversationSession session = await sessions.OpenAsync("conversation-1", TestContext.Current.CancellationToken); + IConversationSessions sessions = provider.GetRequiredService().Sessions; + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); Assert.Equal(Path.Combine(_tempRoot, "conversation-1"), session.Workspace); Assert.True(Directory.Exists(Path.Combine(_tempRoot, "conversation-1"))); @@ -43,12 +44,49 @@ public async Task AHostWithNoWorkspaceBound_CreatesNothingAndLeavesWorkspaceNull { using StartedHost provider = await BuildAsync(OneAgentYaml); - IConversationSessions sessions = provider.GetRequiredService().ForSessions("main"); - ConversationSession session = await sessions.OpenAsync("conversation-1", TestContext.Current.CancellationToken); + IConversationSessions sessions = provider.GetRequiredService().Sessions; + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); Assert.Null(session.Workspace); } + [Fact] + public async Task Boot_SweepsTheWorkspaceRootExactlyOnce() + { + FakeTimeProvider clock = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + _ = Directory.CreateDirectory(_tempRoot); + + string stale = MarkedFolder("stale", clock, TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(1)); + string fresh = MarkedFolder("fresh", clock, TimeSpan.FromMinutes(9) + TimeSpan.FromSeconds(59)); + + using StartedHost provider = await BuildAsync(OneAgentYaml, options => + { + _ = options.UseWorkspace(_tempRoot); + options.TimeProvider = clock; + }); + + Assert.False(Directory.Exists(stale)); + Assert.True(Directory.Exists(fresh)); + + // A folder that only becomes old enough to sweep after boot is never touched: nothing runs the + // sweep a second time, whether on a timer or when a later conversation opens. + string lateStale = MarkedFolder("late-stale", clock, TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(1)); + + IConversationSessions sessions = provider.GetRequiredService().Sessions; + _ = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); + + Assert.True(Directory.Exists(lateStale)); + Assert.True(Directory.Exists(Path.Combine(_tempRoot, "conversation-1"))); + } + + private string MarkedFolder(string name, FakeTimeProvider clock, TimeSpan age) + { + string folder = Directory.CreateDirectory(Path.Combine(_tempRoot, name)).FullName; + File.WriteAllText(ConversationWorkspace.MarkerPathFor(folder), string.Empty); + Directory.SetLastWriteTimeUtc(folder, clock.GetUtcNow().UtcDateTime - age); + return folder; + } + [Fact] public async Task ARootThatCannotBeCreated_FailsStartupWithThePathAndTheOption() { @@ -69,8 +107,8 @@ public async Task AMemoryAgent_WithAWorkspaceRootBound_Starts() { using StartedHost provider = await BuildAsync(MemoryAgentYaml, options => options.UseWorkspace(_tempRoot)); - IConversationSessions sessions = provider.GetRequiredService().ForSessions("main"); - ConversationSession session = await sessions.OpenAsync("conversation-1", TestContext.Current.CancellationToken); + IConversationSessions sessions = provider.GetRequiredService().Sessions; + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); Assert.StartsWith(_tempRoot, session.Workspace, StringComparison.Ordinal); } @@ -104,8 +142,8 @@ public async Task AFilesAgent_WithAWorkspaceRootBound_Starts() { using StartedHost provider = await BuildAsync(FilesAgentYaml, options => options.UseWorkspace(_tempRoot)); - IConversationSessions sessions = provider.GetRequiredService().ForSessions("main"); - ConversationSession session = await sessions.OpenAsync("conversation-1", TestContext.Current.CancellationToken); + IConversationSessions sessions = provider.GetRequiredService().Sessions; + ConversationSession session = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", null, TestContext.Current.CancellationToken); Assert.StartsWith(_tempRoot, session.Workspace, StringComparison.Ordinal); } diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs new file mode 100644 index 00000000..b55e603e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/HttpSessionOwnerReuseTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Text; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// The HTTP Responses path runs every turn of a live conversation on the one session the owner holds, so + /// consecutive turns share one shell and read the transcript at most once per load (probe P1, issue #24). + /// An idle unload ends that session: the next turn gets a new shell and an empty workspace folder, and + /// reads the history back from the store. + /// + public sealed class HttpSessionOwnerReuseTests : IDisposable + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "run the shell tool once, then answer done", shell: { kind: local } } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "agentcore-owner-reuse-" + Guid.NewGuid().ToString("N")); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact(Timeout = 60_000)] + public async Task TwoResponsesTurns_OnANewConversation_LeaveOneLiveShellAndMakeNoFullTranscriptReads() + { + CountingConversationStore store = new(); + ShellPidChatClient client = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, client, options => options.UseWorkspace(_root).UseConversationStores(store)); + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + + using HttpResponseMessage first = await PostAsync(host.Client, conversation, "go"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + using HttpResponseMessage second = await PostAsync(host.Client, conversation, "go again"); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + + Assert.Equal(2, client.ToolResults.Count); + int firstPid = ShellPidChatClient.ParsePid(client.ToolResults[0]); + int secondPid = ShellPidChatClient.ParsePid(client.ToolResults[1]); + + // The second turn ran on the same live session, so the same shell served both. + Assert.Equal(firstPid, secondPid); + Assert.True(Directory.Exists($"/proc/{firstPid}"), $"the one shell (pid {firstPid}) should still be alive."); + + // Turn 1 opens a brand-new conversation and reads nothing (no words exist yet); turn 2 reuses + // the live session instead of rebuilding it, so it reads nothing either. + Assert.Equal(0, store.FullReads); + } + + [Fact(Timeout = 60_000)] + public async Task AConversationThatAlreadyHasWords_OnlyTheFirstTurnReadsTheTranscript_LaterLiveTurnsReadNone() + { + CountingConversationStore store = new(); + ShellPidChatClient client = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, client, options => options.UseWorkspace(_root).UseConversationStores(store)); + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + + // Seeded directly on the store, as another process (or an earlier run of this one) would + // have left it: this process has never built a session for this id yet. + _ = await store.CreateAsync(conversation, Ct); + _ = await store.AppendAsync( + conversation, + [new ConversationMessageDraft(0, new ChatMessage(ChatRole.User, "earlier turn"), "m0")], + state: null, + Ct); + + using HttpResponseMessage first = await PostAsync(host.Client, conversation, "go"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(1, store.FullReads); + + using HttpResponseMessage second = await PostAsync(host.Client, conversation, "go again"); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(1, store.FullReads); + + using HttpResponseMessage third = await PostAsync(host.Client, conversation, "and once more"); + Assert.Equal(HttpStatusCode.OK, third.StatusCode); + Assert.Equal(1, store.FullReads); + } + + [Fact(Timeout = 60_000)] + public async Task AThirdTurnElevenMinutesLater_GetsANewShellWithHistoryIntactAndAnEmptyWorkspaceFolder() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + ShellPidChatClient client = new(); + CountingConversationStore store = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + client, + options => + { + _ = options.UseWorkspace(_root).UseConversationStores(store); + options.TimeProvider = clock; + }); + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + + client.Command = "echo $$; touch left-behind.txt"; + using HttpResponseMessage first = await PostAsync(host.Client, conversation, "go"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + string folder = (await ResolveLiveAsync(host, conversation)).Workspace!; + Assert.True(File.Exists(Path.Combine(folder, "left-behind.txt")), "the first turn's shell should write into the workspace."); + + client.Command = "echo $$"; + using HttpResponseMessage second = await PostAsync(host.Client, conversation, "go again"); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + + // Past the 10-minute default idle timeout: the live session unloads (no words lost, store 0 + // still holds them), its shell stops, and its workspace folder is deleted. + clock.Advance(TimeSpan.FromMinutes(11)); + + using HttpResponseMessage third = await PostAsync(host.Client, conversation, "one more time"); + Assert.Equal(HttpStatusCode.OK, third.StatusCode); + + Assert.Equal(3, client.ToolResults.Count); + int secondPid = ShellPidChatClient.ParsePid(client.ToolResults[1]); + int thirdPid = ShellPidChatClient.ParsePid(client.ToolResults[2]); + + Assert.NotEqual(secondPid, thirdPid); + Assert.False(Directory.Exists($"/proc/{secondPid}"), "the unloaded turn's shell should have stopped."); + Assert.True(Directory.Exists($"/proc/{thirdPid}"), "the third turn's own shell should be alive."); + + ConversationSession live = await ResolveLiveAsync(host, conversation); + Assert.NotNull(live.Workspace); + Assert.False(File.Exists(Path.Combine(folder, "left-behind.txt")), "the unload should delete the first session's folder."); + Assert.Empty(Directory.EnumerateFileSystemEntries(live.Workspace!)); + + // The third turn's first model call is the fifth request: each turn calls the model, then answers the tool. + Assert.Equal(["go", "go again", "one more time"], client.UserWords[4]); + } + + private static async Task ResolveLiveAsync(ResponsesHost host, string conversation) + { + IConversationSessions sessions = host.Services.GetRequiredService().Sessions; + return await sessions.TryGetAsync("main", conversation, Ct) + ?? throw new InvalidOperationException("the conversation should still be live after the third turn."); + } + + private static Task PostAsync(HttpClient client, string conversation, string input) + { + HttpRequestMessage request = new(HttpMethod.Post, "/v1/main/responses") + { + Content = new StringContent( + /*lang=json,strict*/ $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "{{input}}" }""", + Encoding.UTF8, + "application/json"), + }; + + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs new file mode 100644 index 00000000..02750309 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponseContinuationTests.cs @@ -0,0 +1,373 @@ +using System.Globalization; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// Response continuation after the 2026-09-26 redesign (D1-D4): a response id stores no snapshot of + /// its own, an old previous_response_id continues from the conversation's current state rather + /// than branching, and response ids — never conversations — are what the retention sweep ages out. + /// + public sealed class ResponseContinuationTests + { + private const string SimpleYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: greeter + """; + + private const string TestStoreYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + private const string TodoYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: solo, instructions: "answer one", todos: true } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: solo + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Promoted from the response-continuation probe (Q3): an old previous_response_id is not a + // branch (D2). It runs at the conversation's current turn index and sees the current todos, live + // and after the session has unloaded and rebuilt from store 0. + [Theory(Timeout = 60_000)] + [InlineData(false)] + [InlineData(true)] + public async Task AnOldResponseId_ContinuesAtTheCurrentTurnIndex_AndSeesTheCurrentTodos(bool unloadFirst) + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + CountingConversationStore store = new(); + ScriptedClient client = new(todoOnTurns: [1, 2]); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TodoYaml, + client, + o => + { + _ = o.UseConversationStores(store); + o.TimeProvider = clock; + }); + + JsonNode first = await PostAsync(host, null, "t1 first question"); + string r1 = first["id"]!.GetValue(); + string conversation = first["conversation"]!["id"]!.GetValue(); + _ = await PostAsync(host, r1, "t2 second question"); + + if (unloadFirst) + { + clock.Advance(TimeSpan.FromMinutes(11)); + } + + JsonNode third = await PostAsync(host, r1, "t3 branch question"); + + // A real branch off r1 would run as turn 1. It runs as turn 2, after t2. + Assert.Equal("2", third["metadata"]!["turn_index"]!.GetValue()); + Assert.Equal(conversation, third["conversation"]!["id"]!.GetValue()); + + IReadOnlyList seen = client.Requests[^1]; + Assert.Contains(seen, m => m.Text.Contains("t2 second question", StringComparison.Ordinal)); + Assert.Contains(seen, m => m.Text.Contains("item-2", StringComparison.Ordinal)); + } + + // B1: a chained turn carries no conversation id of its own (the request only names + // previous_response_id), so the answer must resolve the real owner rather than echo a made-up one. + [Fact(Timeout = 60_000)] + public async Task AChainedAnswer_ConversationId_EqualsTheConversationTheFirstTurnCreated() + { + using FragmentingChatClient reply = new("answer one", "answer two"); + await using ResponsesHost host = await ResponsesHost.StartAsync(SimpleYaml, reply); + + JsonNode first = await PostAsync(host, null, "first question"); + string conversation = first["conversation"]!["id"]!.GetValue(); + string r1 = first["id"]!.GetValue(); + + JsonNode second = await PostAsync(host, r1, "second question"); + + Assert.Equal(conversation, second["conversation"]!["id"]!.GetValue()); + } + + // Drives the real hosted ConversationSweeper, not a direct SweepAsync call: the same clock backs + // its PeriodicTimer and the store's own created_at, so one Advance past both the hourly tick and + // the retention window lets production code do the sweeping. + [Fact(Timeout = 60_000)] + public async Task AResponseIdOlderThanResponseRetention_AfterTheHostedSweeperRuns_Returns404() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + SweepSignalingConversationStore store = new(clock); + using FragmentingChatClient reply = new("answer one"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TestStoreYaml, + reply, + o => + { + _ = o.UseConversationStores(store); + o.TimeProvider = clock; + o.ResponseRetention = TimeSpan.FromDays(30); + }); + + JsonNode first = await PostAsync(host, null, "hello"); + string r1 = first["id"]!.GetValue(); + + Task sweep = store.ArmNextSweep(); + clock.Advance(TimeSpan.FromDays(31)); + Assert.Equal(1, await sweep.WaitAsync(TimeSpan.FromSeconds(5), Ct)); + + using HttpResponseMessage response = await host.PostAsync( + $$"""{ "stream": false, "previous_response_id": "{{r1}}", "input": "again" }"""); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + JsonNode body = await ResponsesHost.ReadJsonAsync(response); + Assert.Equal("continuation_not_found", body["error"]!["code"]!.GetValue()); + } + + // Same setup and the same time advance as the test above, so a flip of ResponseRetention alone + // is what tells them apart: here the hosted sweeper's ExecuteAsync returns before it ever builds + // a timer, so there is no pass to wait for. + [Fact(Timeout = 60_000)] + public async Task ResponseRetentionNull_TheHostedSweeperNeverRuns_SoTheSameIdStillResolves() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + using FragmentingChatClient reply = new("answer one", "answer two"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TestStoreYaml, + reply, + o => + { + _ = o.UseConversationStores(new TestConversationStoreAdapter(new InMemoryConversationStore(clock))); + o.TimeProvider = clock; + o.ResponseRetention = null; + }); + + JsonNode first = await PostAsync(host, null, "hello"); + string r1 = first["id"]!.GetValue(); + + clock.Advance(TimeSpan.FromDays(31)); + + using HttpResponseMessage response = await host.PostAsync( + $$"""{ "stream": false, "previous_response_id": "{{r1}}", "input": "again" }"""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Timeout = 60_000)] + public async Task AConversationId_KeepsResolving_AfterTheHostedSweeperTakesItsResponseIds() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + SweepSignalingConversationStore store = new(clock); + using FragmentingChatClient reply = new("answer one"); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TestStoreYaml, + reply, + o => + { + _ = o.UseConversationStores(store); + o.TimeProvider = clock; + }); + + JsonNode first = await PostAsync(host, null, "hello"); + string conversation = first["conversation"]!["id"]!.GetValue(); + string r1 = first["id"]!.GetValue(); + + Task sweep = store.ArmNextSweep(); + clock.Advance(TimeSpan.FromDays(31)); + Assert.Equal(1, await sweep.WaitAsync(TimeSpan.FromSeconds(5), Ct)); + Assert.Null(await store.FindContinuationAsync(r1, Ct)); + + // D4: a conversation id is never itself a response_continuation row, so it survives the sweep. + using HttpResponseMessage response = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "again" }"""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static async Task PostAsync(ResponsesHost host, string? previous, string input) + { + string json = previous is null + ? $$"""{ "stream": false, "input": "{{input}}" }""" + : $$"""{ "stream": false, "previous_response_id": "{{previous}}", "input": "{{input}}" }"""; + using HttpResponseMessage response = await host.PostAsync(json); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await ResponsesHost.ReadJsonAsync(response); + } + + /// Hands the host a store the test already holds, as the document's test kind. + private sealed class TestConversationStoreAdapter(IConversationStore store) : IConversationStoreAdapter + { + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(store); + } + } + + /// + /// An in-memory store, on the clock a test also drives, that lets the test await the hosted + /// ConversationSweeper's own next sweep pass rather than calling SweepAsync itself. + /// + private sealed class SweepSignalingConversationStore(TimeProvider clock) + : DelegatingConversationStore(new InMemoryConversationStore(clock)), IConversationStoreAdapter + { + private TaskCompletionSource _swept = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + /// Arms a fresh waiter, so a sweep already in flight cannot be mistaken for the next one. + public Task ArmNextSweep() + { + TaskCompletionSource waiter = new(TaskCreationOptions.RunContinuationsAsynchronously); + _swept = waiter; + return waiter.Task; + } + + public override async ValueTask SweepAsync( + TimeSpan retention, int batchSize = 500, CancellationToken cancellationToken = default) + { + int result = await base.SweepAsync(retention, batchSize, cancellationToken).ConfigureAwait(false); + _ = _swept.TrySetResult(result); + return result; + } + } + + /// + /// Answers "reply to turn N". On the turns named, the first model call adds todo "item-N" and the + /// call after the tool result answers text. + /// + private sealed class ScriptedClient(int[] todoOnTurns) : IChatClient + { + private readonly HashSet _todoOnTurns = [.. todoOnTurns]; + + public List> Requests { get; } = []; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List transcript = [.. messages]; + lock (Requests) + { + Requests.Add(transcript); + } + + await Task.Yield(); + ChatMessage lastUser = transcript.Last(m => m.Role == ChatRole.User && m.Text.StartsWith('t')); + int turn = int.Parse( + lastUser.Text[1..lastUser.Text.IndexOf(' ', StringComparison.Ordinal)], + CultureInfo.InvariantCulture); + bool answeringTool = transcript[^1].Role == ChatRole.Tool; + string id = Guid.NewGuid().ToString("N"); + + AIFunction? add = options?.Tools?.OfType() + .FirstOrDefault(t => t.Name.Contains("add", StringComparison.OrdinalIgnoreCase)); + + if (!answeringTool && _todoOnTurns.Contains(turn) && add is not null) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent( + "call_" + id, + add.Name, + new Dictionary(StringComparer.Ordinal) + { + ["todos"] = new List + { + new Dictionary(StringComparer.Ordinal) { ["title"] = $"item-{turn}" }, + }, + })]) + { ResponseId = id, MessageId = id }; + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, $"reply to turn {turn}") + { + ResponseId = id, + MessageId = id, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesAnswer.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesAnswer.cs new file mode 100644 index 00000000..529a4fcf --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesAnswer.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// Reads what one Responses answer carries. + internal static class ResponsesAnswer + { + /// Reads the assistant text of one answer. + /// The answer. + /// Every output text, joined. + internal static string OutputText(this JsonNode body) + { + return string.Join("", body["output"]?.AsArray() + .SelectMany(o => o?["content"]?.AsArray() ?? []) + .Select(c => c?["text"]?.GetValue() ?? "") ?? []); + } + + /// Reads the id a later turn hangs off: the conversation, or the response. + /// The answer. + /// The continuation id. + internal static string ContinuationId(this JsonNode body) + { + return body["conversation"] is JsonObject conversation + && conversation["id"]?.GetValue() is { Length: > 0 } conversationId + ? conversationId + : body["id"]?.GetValue() + ?? throw new InvalidOperationException("Answer carries no continuation id."); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs new file mode 100644 index 00000000..58374a33 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesApprovalTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// The Responses path when a tool call needs approval: the turn stops with the request in metadata, + /// and only a matching approval answer runs the tool and continues. + /// + public sealed class ResponsesApprovalTests + { + private const string ApprovalYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "send the mail", tools: [ send_email ] } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: greeter + """; + + [Fact] + public async Task GatedToolCall_AnswersWholeWithTheRequestInMetadata() + { + int sent = 0; + await using ResponsesHost host = await ResponsesHost.StartAsync( + ApprovalYaml, + new GatedToolCallingChatClient(), + configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => sent++)))); + + using HttpResponseMessage response = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); + JsonNode body = await ResponsesHost.ReadJsonAsync(response); + + Assert.Equal(string.Empty, body.OutputText()); + string approvals = body["metadata"]!["approvals"]!.GetValue(); + Assert.Contains("send_email", approvals, StringComparison.Ordinal); + Assert.Equal(0, sent); + } + + [Fact] + public async Task ApprovalAnswer_RunsTheToolAndReplies() + { + int sent = 0; + await using ResponsesHost host = await ResponsesHost.StartAsync( + ApprovalYaml, + new GatedToolCallingChatClient(), + configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => sent++)))); + + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); + JsonNode asked = await ResponsesHost.ReadJsonAsync(first); + string convId = asked.ContinuationId(); + string? requestId = JsonDocument.Parse( + asked["metadata"]!["approvals"]!.GetValue()) + .RootElement[0].GetProperty("request_id").GetString(); + + using HttpResponseMessage second = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{convId}}", "input": [], "agentcore": { "approval": { "request_id": "{{requestId}}", "approved": true } } }"""); + JsonNode replied = await ResponsesHost.ReadJsonAsync(second); + + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(1, sent); + Assert.Contains("done.", replied.OutputText(), StringComparison.Ordinal); + } + + [Fact] + public async Task ApprovalAnswerForARequestNothingAsked_IsConflict() + { + await using ResponsesHost host = await ResponsesHost.StartAsync( + ApprovalYaml, + new GatedToolCallingChatClient(), + configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { })))); + + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); + string convId = (await ResponsesHost.ReadJsonAsync(first)).ContinuationId(); + + using HttpResponseMessage second = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{convId}}", "input": [], "agentcore": { "approval": { "request_id": "req-nothing-asked", "approved": true } } }"""); + + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + } + + [Fact] + public async Task ApprovalAnswerWithWords_IsBadRequest() + { + await using ResponsesHost host = await ResponsesHost.StartAsync( + ApprovalYaml, + new GatedToolCallingChatClient(), + configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { })))); + + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); + string convId = (await ResponsesHost.ReadJsonAsync(first)).ContinuationId(); + + using HttpResponseMessage second = await host.PostAsync( + $$"""{ "stream": false, "conversation": "{{convId}}", "input": "send it", "agentcore": { "approval": { "request_id": "req-1", "approved": true } } }"""); + + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + } + + private static ApprovalRequiredAIFunction GatedSendEmail(Action onSend) + { + return new(AIFunctionFactory.Create( + (string to) => + { + onSend(); + return "sent"; + }, + "send_email", + "Send an email.")); + } + + /// A tool source that serves one gated id, standing in for an approval-required host tool. + private sealed class GatedSource(Func build) + : Application.Ports.IToolSource + { + public ValueTask> ProvideAsync( + Application.Tools.Registry.ToolSourceContext context, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>( + [new Application.Tools.Registry.ToolRegistration("send_email", "Send an email.", build)]); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs new file mode 100644 index 00000000..83813b16 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesBusyConversationTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Audit; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A request on a conversation another request still holds waits for the busy mark, then reads the conversation + /// afresh (owner ruling for soak F1). A client that sends one message at a time is never refused; a wait past the + /// limit is. + /// + public sealed class ResponsesBusyConversationTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // Soak F1: a stopped turn files its words after the abort. A next message that read the conversation before + // they landed would take the same turn index, and the store would keep only one of the two. + [Fact(Timeout = 60_000)] + public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnce_KeepsBothTurnsInOrder() + { + // Arrange + TurnHoldingStore store = new(heldTurn: 1); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, new StallOnCueChatClient(), options => options.UseConversationStores(store)); + string conversation = await OpenAsync(host); + + // Act: the client reads the first piece of a streamed reply and drops the connection, as a browser does. + using (HttpClient browser = new(new SocketsHttpHandler { MaxResponseDrainSize = 0 }) { BaseAddress = host.Client.BaseAddress }) + { + using HttpResponseMessage stopped = await PostAsync(browser, conversation, "stall now", stream: true); + await ReadUntilAsync(stopped, StallOnCueChatClient.Piece); + } + + await store.Held.WaitAsync(Ct); + Task sending = PostAsync(host.Client, conversation, "next", stream: false); + + // One host shares one live session, so the wait is in process and the store never refuses a mark. + _ = await Task.WhenAny(sending, Task.Delay(TimeSpan.FromSeconds(1), Ct)); + store.Release(); + using HttpResponseMessage next = await sending; + + // Assert + await AssertBothTurnsKeptInOrderAsync(host, conversation, next); + } + + // The same stopped stream, with the next message sent to another host over the same store: there the busy + // mark in the store is what makes it wait. + [Fact(Timeout = 60_000)] + public async Task AStreamTheClientStopped_ThenTheNextMessageAtOnceOnAnotherHost_KeepsBothTurnsInOrder() + { + // Arrange + TurnHoldingStore store = new(heldTurn: 1); + StallOnCueChatClient model = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); + await using ResponsesHost other = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); + string conversation = await OpenAsync(host); + + // Act + using (HttpClient browser = new(new SocketsHttpHandler { MaxResponseDrainSize = 0 }) { BaseAddress = host.Client.BaseAddress }) + { + using HttpResponseMessage stopped = await PostAsync(browser, conversation, "stall now", stream: true); + await ReadUntilAsync(stopped, StallOnCueChatClient.Piece); + } + + await store.Held.WaitAsync(Ct); + Task sending = PostAsync(other.Client, conversation, "next", stream: false); + _ = await Task.WhenAny(store.MarkRefused, sending); + store.Release(); + using HttpResponseMessage next = await sending; + + // Assert + await AssertBothTurnsKeptInOrderAsync(other, conversation, next); + } + + [Fact(Timeout = 60_000)] + public async Task ATurnStillRunningOnThisHostPastTheWaitLimit_Answers409_AndLeavesATurnRefused() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + new StallOnCueChatClient(), + options => + { + options.TimeProvider = time; + _ = options.UseConversationStores(new TurnHoldingStore(heldTurn: -1)); + }); + string conversation = await OpenAsync(host); + using HttpResponseMessage running = await PostAsync(host.Client, conversation, "stall now", stream: true); + await ReadUntilAsync(running, StallOnCueChatClient.Piece); + + // Act + Task sending = PostAsync(host.Client, conversation, "next", stream: false); + _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.WaitLimit, 1), sending); + time.Advance(ConversationBusyMark.WaitLimit); + using HttpResponseMessage refused = await sending; + + // Assert + Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode); + JsonNode error = (await ResponsesHost.ReadJsonAsync(refused))["error"]!; + Assert.Equal(ResponsesTurnConflict.Code, error["code"]!.GetValue()); + Assert.Equal(ResponsesTurnConflict.BusyMessage, error["message"]!.GetValue()); + + QueuedAuditSink queue = host.Services.GetRequiredService(); + await queue.FlushAsync(Ct); + AuditEvent refusal = Assert.Single( + Assert.IsType(queue.Store).EventsOf(conversation), item => item.Kind == AuditEventKind.TurnRefused); + Assert.Equal(("busy", null), (refusal.Payload[AuditPayloadKeys.RefusedReason], refusal.TurnIndex)); + } + + [Fact(Timeout = 60_000)] + public async Task AConversationHeldPastTheWaitLimit_Answers409_AndLeavesAWarningAndATurnRefused() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + using RecordingLoggerFactory logs = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync( + Yaml, + new StallOnCueChatClient(), + options => + { + options.TimeProvider = time; + options.LoggerFactory = logs; + _ = options.UseConversationStores(new TurnHoldingStore(heldTurn: -1)); + }); + string conversation = await OpenAsync(host); + IConversationStore store = host.Services.GetRequiredService(); + _ = await store.TryMarkBusyAsync(conversation, "other-host", TimeSpan.FromHours(1), Ct); + + // Act + Task sending = PostAsync(host.Client, conversation, "next", stream: false); + _ = await Task.WhenAny(time.WaitForTimersAsync(time.GetUtcNow() + ConversationBusyMark.FirstPoll, 1), sending); + time.Advance(ConversationBusyMark.WaitLimit); + using HttpResponseMessage refused = await sending; + + // Assert + Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode); + JsonNode error = (await ResponsesHost.ReadJsonAsync(refused))["error"]!; + Assert.Equal(ResponsesTurnConflict.Code, error["code"]!.GetValue()); + Assert.Equal(ResponsesTurnConflict.BusyMessage, error["message"]!.GetValue()); + + QueuedAuditSink queue = host.Services.GetRequiredService(); + await queue.FlushAsync(Ct); + AuditEvent refusal = Assert.Single( + Assert.IsType(queue.Store).EventsOf(conversation), item => item.Kind == AuditEventKind.TurnRefused); + Assert.Equal(("busy", null), (refusal.Payload[AuditPayloadKeys.RefusedReason], refusal.TurnIndex)); + CapturedLine line = Assert.Single(logs.Of(40)); + Assert.Equal((LogLevel.Warning, "busy"), (line.Level, line.Field("Reason"))); + } + + private static async Task AssertBothTurnsKeptInOrderAsync(ResponsesHost host, string conversation, HttpResponseMessage next) + { + Assert.Equal(HttpStatusCode.OK, next.StatusCode); + JsonNode answer = await ResponsesHost.ReadJsonAsync(next); + Assert.Equal("2", answer["metadata"]!["turn_index"]!.GetValue()); + List rows = await StoredRows.SettleAsync(host.Services, conversation, 6); + Assert.Equal( + [(0, "open"), (1, "stall now"), (2, "next")], + rows.Where(row => row.Content.Role.Value == "user").Select(row => (row.TurnIndex, row.Content.Text))); + } + + private static async Task OpenAsync(ResponsesHost host) + { + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + using HttpResponseMessage open = await PostAsync(host.Client, conversation, "open", stream: false); + Assert.Equal(HttpStatusCode.OK, open.StatusCode); + return conversation; + } + + private static Task PostAsync(HttpClient client, string conversation, string input, bool stream) + { + string flag = stream ? "true" : "false"; + HttpRequestMessage request = new(HttpMethod.Post, "/v1/main/responses") + { + Content = new StringContent( + /*lang=json,strict*/ $$"""{ "stream": {{flag}}, "conversation": "{{conversation}}", "input": "{{input}}" }""", + Encoding.UTF8, + "application/json"), + }; + + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); + } + + private static async Task ReadUntilAsync(HttpResponseMessage response, string text) + { + Stream body = await response.Content.ReadAsStreamAsync(Ct); + using StreamReader reader = new(body, Encoding.UTF8, leaveOpen: true); + while (await reader.ReadLineAsync(Ct) is { } line && !line.Contains(text, StringComparison.Ordinal)) + { + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs new file mode 100644 index 00000000..0359a73c --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesConversationInUseTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Ports; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// U9 (owner ruling, #23): while one entry holds a conversation live, a request from another entry on + /// that same id is blocked rather than switched. The Responses path maps the owner's refusal to its + /// own 409, distinct from the busy/turn-conflict 409s answers. + /// + public sealed class ResponsesConversationInUseTests + { + private const string TwoEntryYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + phone: + agent: greeter + chat: + agent: greeter + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 60_000)] + public async Task ARequestFromAnotherEntryOnALiveConversation_Answers409WithItsOwnMessage() + { + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, new FragmentingChatClient("phone reply")); + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + + using HttpResponseMessage first = await PostAsync(host.Client, "phone", conversation, "hi"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + using HttpResponseMessage second = await PostAsync(host.Client, "chat", conversation, "hi"); + + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + JsonNode error = (await ResponsesHost.ReadJsonAsync(second))["error"]!; + string message = error["message"]!.GetValue(); + + Assert.Equal("conversation_in_use", error["code"]!.GetValue()); + Assert.NotEqual(ResponsesTurnConflict.Code, error["code"]!.GetValue()); + Assert.Contains("phone", message, StringComparison.Ordinal); + Assert.NotEqual(ResponsesTurnConflict.BusyMessage, message); + Assert.NotEqual(ResponsesTurnConflict.ConflictMessage, message); + } + + [Fact(Timeout = 60_000)] + public async Task OnceThatEntrysSessionIsGone_AnotherEntryOpensTheSameIdWithoutTheRefusal() + { + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoEntryYaml, new FragmentingChatClient("phone reply", "chat reply")); + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + + using HttpResponseMessage first = await PostAsync(host.Client, "phone", conversation, "hi"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + await CloseAsync(host, "phone", conversation); + + using HttpResponseMessage second = await PostAsync(host.Client, "chat", conversation, "hi"); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + } + + private static async Task CloseAsync(ResponsesHost host, string entry, string conversation) + { + IConversationSessions sessions = host.Services.GetRequiredService().Sessions; + await sessions.CloseAsync(entry, conversation, Ct); + } + + private static Task PostAsync(HttpClient client, string entry, string conversation, string input) + { + HttpRequestMessage request = new(HttpMethod.Post, $"/v1/{entry}/responses") + { + Content = new StringContent( + /*lang=json,strict*/ $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "{{input}}" }""", + Encoding.UTF8, + "application/json"), + }; + + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs new file mode 100644 index 00000000..2c13e359 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesDroppedWriteTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Text.Json.Nodes; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A turn whose store 1 append failed once is still filed under its response id: no other session holds that id, + /// and the next turn reads the store back before it runs. Only a turn the store refused is never filed + /// (). + /// + public sealed class ResponsesDroppedWriteTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + [Fact] + public async Task AResponseWhoseAppendDroppedOnce_CanBeChainedFrom() + { + DownStore store = new(); + using FragmentingChatClient reply = new("answer one", "answer two", "answer three"); + await using ResponsesHost host = await ResponsesHost.StartAsync(Yaml, reply, options => options.UseConversationStores(store)); + + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "first" }"""); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + string firstId = (await ResponsesHost.ReadJsonAsync(first))["id"]!.GetValue(); + + store.Down = true; + using HttpResponseMessage second = await host.PostAsync( + $$"""{ "stream": false, "previous_response_id": "{{firstId}}", "input": "second" }"""); + store.Down = false; + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + string secondId = (await ResponsesHost.ReadJsonAsync(second))["id"]!.GetValue(); + + using HttpResponseMessage third = await host.PostAsync( + $$"""{ "stream": false, "previous_response_id": "{{secondId}}", "input": "third" }"""); + + Assert.Equal(HttpStatusCode.OK, third.StatusCode); + JsonNode answer = await ResponsesHost.ReadJsonAsync(third); + Assert.Contains("answer three", answer.OutputText(), StringComparison.Ordinal); + } + + /// An in-memory store whose appends fail while is set. It is its own adapter, of kind test. + private sealed class DownStore() : DelegatingConversationStore(new InMemoryConversationStore()), IConversationStoreAdapter + { + public volatile bool Down; + + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + return Down + ? throw new InvalidOperationException("the conversation store is down.") + : base.AppendAsync(conversationId, messages, state, cancellationToken); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs new file mode 100644 index 00000000..21830a70 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesFilingAfterAbortTests.cs @@ -0,0 +1,112 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.Sessions; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// Filing after the client aborts runs on no host token, so it needs a bound of its own: the one the + /// rest of the work after the reply runs under (design section 7, item 4). + /// + public sealed class ResponsesFilingAfterAbortTests + { + private static readonly TimeSpan FailureGuard = TimeSpan.FromSeconds(10); + + [Fact] + public async Task AFilingThatNeverEndsIsStoppedByTheBoundAndLogged() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + using RecordingLoggerFactory logs = new(); + TaskCompletionSource never = new(); + + Task filing = ResponsesTurnStream.FileAfterAbortAsync( + _ => never.Task, "conv-f5", time, logs.CreateLogger("test")); + + time.Advance(ConversationSession.TurnCompletionTimeout - TimeSpan.FromTicks(1)); + Assert.False(filing.IsCompleted); + + time.Advance(TimeSpan.FromTicks(1)); + await filing.WaitAsync(FailureGuard, TestContext.Current.CancellationToken); + + CapturedLine line = Assert.Single(logs.Of(1)); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.Equal("conv-f5", line.Field("ConversationId")); + } + + // Design section 3: the turn commits even when the host cancels mid-stream, so the session is still + // filed, on a token that outlives the abort. A store that honours the aborted token would refuse it. + [Fact] + public async Task AStreamTheClientAborted_StillFilesTheSession() + { + CancellationToken ct = TestContext.Current.CancellationToken; + TokenHonouringStore store = new(); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(TwoStepChatClient.Yaml), + new AgentCompilationContext(new RoutingChatClientFactory(new StallingChatClient())) { ConversationStore = store })["main"]; + InMemoryConversationSessions sessions = new( + new Dictionary(StringComparer.Ordinal) + { + ["main"] = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)), + }, + TimeSpan.FromMinutes(30), + TimeProvider.System); + AgentCoreAgent agent = new(sessions, "main"); + AgentSession session = await agent.CreateSessionAsync(ct); + ConversationSession conversation = session.GetService()!; + ResponsesTurn turn = new( + agent, + new AgentCoreAgentSessionStore(store), + session, + conversation, + new ChatMessage(ChatRole.User, "hi"), + Origin: null, + ResponseId: "resp-aborted", + ConversationId: conversation.ConversationId); + DefaultHttpContext http = new() { RequestServices = new ServiceCollection().BuildServiceProvider() }; + http.Response.Body = new MemoryStream(); + using CancellationTokenSource aborted = new(); + await aborted.CancelAsync(); + + _ = await Assert.ThrowsAnyAsync( + () => ResponsesTurnStream.WriteAsync(http, turn, dialect: false, aborted.Token)); + + Assert.Equal(conversation.ConversationId, await store.FindContinuationAsync("resp-aborted", ct)); + } + + [Fact] + public async Task AFilingThatEndsInTimeLogsNothing() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + using RecordingLoggerFactory logs = new(); + + await ResponsesTurnStream.FileAfterAbortAsync( + _ => Task.CompletedTask, "conv-f5", time, logs.CreateLogger("test")); + + Assert.Empty(logs.Lines); + } + + private sealed class TokenHonouringStore() : DelegatingConversationStore(new InMemoryConversationStore()) + { + public override ValueTask SaveContinuationAsync( + string responseId, string conversationId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return base.SaveContinuationAsync(responseId, conversationId, cancellationToken); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs new file mode 100644 index 00000000..681543b6 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepAbortTests.cs @@ -0,0 +1,74 @@ +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A text stream aborted inside a reply of two steps keeps every word it yielded, each step's words in their + /// own place around the finished tool pair (owner ruling 2026-09-23). + /// + public sealed class ResponsesMultiStepAbortTests + { + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); + + // Probe T2_TextStreamAbortInSecondStep_KeepsAllYieldedText. + [Fact(Timeout = 30_000)] + public async Task AbortDuringStepTwo_KeepsStepOneInPlaceAndStepTwosYieldedWords() + { + TwoStepChatClient model = new( + ["Let me check.", TwoStepChatClient.Call], + ["Your order", TwoStepChatClient.Pause, " ships today"]); + await using ResponsesHost host = await ResponsesHost.StartAsync(TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool); + + HttpResponseMessage response = await host.PostAsync( + /*lang=json,strict*/ """{ "stream": true, "conversation": "abort-step-two", "input": "first question" }"""); + await ReadDeltasUntilAsync(response, "Let me check.Your order"); + await model.Paused.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + response.Dispose(); + + List rows = await StoredRows.SettleAsync(host.Services, "abort-step-two", 4); + StoredRows.AssertTurn(rows, ("user", "first question", false, false), ("assistant", "Let me check.", true, false), ("tool", "", false, true), ("assistant", "Your order", false, false)); + } + + // Probe T3_TextStreamAbortAfterToolResultBeforeStepTwoText_KeepsStepOneText. + [Fact(Timeout = 30_000)] + public async Task AbortAfterTheToolResultBeforeStepTwoSpeaks_KeepsStepOneInPlace() + { + TwoStepChatClient model = new( + ["Let me check.", TwoStepChatClient.Call], + [TwoStepChatClient.Pause, "Your order"]); + await using ResponsesHost host = await ResponsesHost.StartAsync(TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool); + + HttpResponseMessage response = await host.PostAsync( + /*lang=json,strict*/ """{ "stream": true, "conversation": "abort-before-step-two", "input": "first question" }"""); + await ReadDeltasUntilAsync(response, "Let me check."); + await model.Paused.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + response.Dispose(); + + List rows = await StoredRows.SettleAsync(host.Services, "abort-before-step-two", 3); + StoredRows.AssertTurn(rows, ("user", "first question", false, false), ("assistant", "Let me check.", true, false), ("tool", "", false, true)); + } + + private static async Task ReadDeltasUntilAsync(HttpResponseMessage response, string expected) + { + using StreamReader reader = new(await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken), Encoding.UTF8, leaveOpen: true); + StringBuilder text = new(); + using CancellationTokenSource deadline = new(Bound); + while (text.ToString() != expected) + { + string? line = await reader.ReadLineAsync(deadline.Token); + Assert.NotNull(line); + if (line.StartsWith("data: ", StringComparison.Ordinal) + && JsonNode.Parse(line["data: ".Length..]) is JsonObject frame + && frame["type"]?.GetValue() == "response.output_text.delta") + { + _ = text.Append(frame["delta"]!.GetValue()); + } + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepFaultTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepFaultTests.cs new file mode 100644 index 00000000..12c112e9 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesMultiStepFaultTests.cs @@ -0,0 +1,38 @@ +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A text reply of two steps whose second step's tool fails until the run ends: the user saw step one's words + /// and then the fallback line, so the history keeps both, in that order (owner ruling 2026-09-23). + /// + public sealed class ResponsesMultiStepFaultTests + { + private const string Fallback = "Sorry, the order service did not answer."; + + [Fact(Timeout = 30_000)] + public async Task ToolFaultInStepTwo_KeepsStepOnesWordsThenTheFallback() + { + TwoStepChatClient model = new(["Let me check.", TwoStepChatClient.Call], [TwoStepChatClient.Call]); + await using ResponsesHost host = await ResponsesHost.StartAsync( + TwoStepChatClient.YamlWithFallback(Fallback), model, TwoStepChatClient.BindToolThatFailsAfterItsFirstCall); + + using HttpResponseMessage response = await host.PostAsync( + /*lang=json,strict*/ """{ "stream": true, "conversation": "fault-step-two", "input": "first question" }"""); + List shown = ResponsesHost.TextDeltas(await ResponsesHost.ReadEventsAsync(response)); + + Assert.Equal("Let me check." + Fallback, string.Concat(shown)); + + List rows = await StoredRows.SettleAsync(host.Services, "fault-step-two", 4); + string all = StoredRows.Describe(rows); + Assert.True(rows[0].Content is { Text: "first question" } && rows[0].Content.Role == ChatRole.User, all); + Assert.True(rows[1].Content is { Text: "Let me check." } && rows[1].Content.Contents.OfType().Any(), all); + Assert.True(rows[2].Content.Contents.OfType().Any(), all); + Assert.True(rows[^1].Content is { Text: Fallback } && rows[^1].Content.Role == ChatRole.Assistant, all); + Assert.True(rows.Count(row => row.Content.Text.Length > 0) == 3, all); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs new file mode 100644 index 00000000..c5b5fc2b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSharedSessionWaitTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A Responses request runs on the one live session its conversation has on this host. It waits for a turn that + /// another door started on that session, and it follows the conversation to a new session when the one it waited + /// on was closed meanwhile. + /// + public sealed class ResponsesSharedSessionWaitTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A voice turn, or AgentCoreAgent.RunAsync on another AgentSession of the id, runs on the same live session. + [Fact(Timeout = 60_000)] + public async Task AMessageSentWhileATurnStartedAnotherWayRuns_WaitsForIt_ThenRunsAsTheNextTurn() + { + // Arrange + await using ResponsesHost host = await StartAsync(); + string conversation = await OpenAsync(host); + IConversationSessions sessions = host.Services.GetRequiredService().Sessions; + ConversationSession live = await sessions.GetOrOpenAsync("main", conversation, null, Ct); + + using CancellationTokenSource stop = new(); + IAsyncEnumerator other = live + .RunTurnMessageStreamingAtOriginAsync(new ChatMessage(ChatRole.User, "stall now"), null, stop.Token) + .GetAsyncEnumerator(stop.Token); + Assert.True(await other.MoveNextAsync()); + + // Act + Task sending = PostAsync(host.Client, conversation, "next", stream: false); + _ = await Task.WhenAny(sending, Task.Delay(TimeSpan.FromSeconds(1), Ct)); + await stop.CancelAsync(); + await DrainAsync(other); + using HttpResponseMessage next = await sending; + + // Assert + Assert.Equal(HttpStatusCode.OK, next.StatusCode); + JsonNode answer = await ResponsesHost.ReadJsonAsync(next); + Assert.Equal("2", answer["metadata"]!["turn_index"]!.GetValue()); + List rows = await StoredRows.SettleAsync(host.Services, conversation, 6); + Assert.Equal( + [(0, "open"), (1, "stall now"), (2, "next")], + rows.Where(row => row.Content.Role.Value == "user").Select(row => (row.TurnIndex, row.Content.Text))); + } + + [Fact(Timeout = 60_000)] + public async Task AMessageWaitingOnARequest_WhenTheSessionIsClosedMeanwhile_RunsOnTheReopenedConversation() + { + // Arrange + await using ResponsesHost host = await StartAsync(); + string conversation = await OpenAsync(host); + IConversationSessions sessions = host.Services.GetRequiredService().Sessions; + using HttpClient browser = new(new SocketsHttpHandler { MaxResponseDrainSize = 0 }) { BaseAddress = host.Client.BaseAddress }; + HttpResponseMessage stalled = await PostAsync(browser, conversation, "stall now", stream: true); + await ReadUntilAsync(stalled, StallOnCueChatClient.Piece); + + // Act + Task sending = PostAsync(host.Client, conversation, "next", stream: false); + _ = await Task.WhenAny(sending, Task.Delay(TimeSpan.FromMilliseconds(300), Ct)); + Task closing = sessions.CloseAsync("main", conversation, Ct).AsTask(); + _ = await Task.WhenAny(closing, Task.Delay(TimeSpan.FromMilliseconds(300), Ct)); + stalled.Dispose(); + await closing; + using HttpResponseMessage next = await sending; + + // Assert + Assert.Equal(HttpStatusCode.OK, next.StatusCode); + JsonNode answer = await ResponsesHost.ReadJsonAsync(next); + Assert.Equal("2", answer["metadata"]!["turn_index"]!.GetValue()); + List rows = await StoredRows.SettleAsync(host.Services, conversation, 6); + Assert.Equal( + [(0, "open"), (1, "stall now"), (2, "next")], + rows.Where(row => row.Content.Role.Value == "user").Select(row => (row.TurnIndex, row.Content.Text))); + } + + private static Task StartAsync() + { + return ResponsesHost.StartAsync( + Yaml, new StallOnCueChatClient(), options => options.UseConversationStores(new TurnHoldingStore(heldTurn: -1))); + } + + private static async Task DrainAsync(IAsyncEnumerator updates) + { + try + { + while (await updates.MoveNextAsync()) + { + } + } + catch (OperationCanceledException) + { + } + + await updates.DisposeAsync(); + } + + private static async Task OpenAsync(ResponsesHost host) + { + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + using HttpResponseMessage open = await PostAsync(host.Client, conversation, "open", stream: false); + Assert.Equal(HttpStatusCode.OK, open.StatusCode); + return conversation; + } + + private static Task PostAsync(HttpClient client, string conversation, string input, bool stream) + { + string flag = stream ? "true" : "false"; + HttpRequestMessage request = new(HttpMethod.Post, "/v1/main/responses") + { + Content = new StringContent( + /*lang=json,strict*/ $$"""{ "stream": {{flag}}, "conversation": "{{conversation}}", "input": "{{input}}" }""", + Encoding.UTF8, + "application/json"), + }; + + return client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Ct); + } + + private static async Task ReadUntilAsync(HttpResponseMessage response, string text) + { + Stream body = await response.Content.ReadAsStreamAsync(Ct); + using StreamReader reader = new(body, Encoding.UTF8, leaveOpen: true); + while (await reader.ReadLineAsync(Ct) is { } line && !line.Contains(text, StringComparison.Ordinal)) + { + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs new file mode 100644 index 00000000..fa295919 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesSlowStoreTests.cs @@ -0,0 +1,224 @@ +using System.Net; +using System.Text.Json.Nodes; +using AgentCore.Application.Audit; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// A store that answers a turn's append later than . The + /// turn publishes only what the store's answer says, however late it comes (owner ruling 2026-09-24, "refuse at + /// save"). + /// + public sealed class ResponsesSlowStoreTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + private static readonly TimeSpan PastTheBound = ConversationSession.TurnCompletionTimeout + TimeSpan.FromSeconds(1); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 60_000)] + public async Task ARefusalThatComesAfterTheBound_FilesNothing_AndPublishesNoCommit() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + HoldingStore store = new(holdAppendOfTurn: 1, holdAppendNumber: 2); + RendezvousChatClient model = new(parties: 2); + + // One host shares one live session, so the two racing sessions need two hosts over the one store. + await using ResponsesHost host = await StartAsync(model, store, time); + await using ResponsesHost other = await StartAsync(model, store, time); + string conversation = await OpenAsync(host); + + // Act + Task first = host.PostAsync(Race(conversation, "race A", "user-a")); + Task second = other.PostAsync(Race(conversation, "race B", "user-b")); + await store.Held.WaitAsync(Ct); + await PassTheBoundAsync(time); + store.Release(); + using HttpResponseMessage a = await first; + using HttpResponseMessage b = await second; + List eventsA = Parse(await ResponsesHost.ReadEventsAsync(a)); + List eventsB = Parse(await ResponsesHost.ReadEventsAsync(b)); + + // Assert + List won = Assert.Single([eventsA, eventsB], events => Type(events[^1]) == "response.completed"); + List lost = Assert.Single([eventsA, eventsB], events => Type(events[^1]) == "error"); + Assert.Equal(ResponsesTurnConflict.Code, lost[^1]["code"]!.GetValue()); + Assert.DoesNotContain(lost, frame => frame.ContainsKey(TurnStreamPart.MessageCommitted)); + Assert.Contains(won, frame => frame.ContainsKey(TurnStreamPart.MessageCommitted)); + + IConversationStore stored = host.Services.GetRequiredService(); + Assert.Null(await stored.FindContinuationAsync(ResponseId(lost), Ct)); + Assert.Equal(conversation, await stored.FindContinuationAsync(ResponseId(won), Ct)); + + Assert.Equal([0, 1], [.. (await TurnCompletedIndexesAsync(host, conversation)).Concat(await TurnCompletedIndexesAsync(other, conversation)).Order()]); + } + + // The other half of the same rule: a slow store that keeps the turn still ends it as saved. + [Fact(Timeout = 60_000)] + public async Task ASaveThatLandsAfterTheBound_EndsTheTurnAsSaved_AndFilesIt() + { + // Arrange + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + HoldingStore store = new(holdAppendOfTurn: 1, holdAppendNumber: 1); + RendezvousChatClient model = new(parties: 1); + await using ResponsesHost host = await StartAsync(model, store, time); + string conversation = await OpenAsync(host); + + // Act + Task slow = host.PostAsync(Race(conversation, "race A", "user-a")); + await store.Held.WaitAsync(Ct); + await PassTheBoundAsync(time); + store.Release(); + using HttpResponseMessage response = await slow; + List events = Parse(await ResponsesHost.ReadEventsAsync(response)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("response.completed", Type(events[^1])); + Assert.Contains(events, frame => frame.ContainsKey(TurnStreamPart.MessageCommitted)); + IConversationStore stored = host.Services.GetRequiredService(); + Assert.Equal(conversation, await stored.FindContinuationAsync(ResponseId(events), Ct)); + Assert.Equal([0, 1], await TurnCompletedIndexesAsync(host, conversation)); + } + + private static async Task StartAsync(RendezvousChatClient model, HoldingStore store, FakeTimeProvider time) + { + return await ResponsesHost.StartAsync(Yaml, model, options => + { + options.TimeProvider = time; + _ = options.UseConversationStores(new HoldingStoreAdapter(store)); + }); + } + + private static async Task OpenAsync(ResponsesHost host) + { + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + using HttpResponseMessage open = await host.PostAsync( + /*lang=json,strict*/ $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "open" }"""); + Assert.Equal(HttpStatusCode.OK, open.StatusCode); + return conversation; + } + + private static string Race(string conversation, string input, string messageId) + { + return /*lang=json,strict*/ $$"""{ "stream": true, "conversation": "{{conversation}}", "input": "{{input}}", "agentcore": { "message_id": "{{messageId}}" } }"""; + } + + /// Moves the clock past the bound, once a bound the turn armed on it, if any, is armed. + private static async Task PassTheBoundAsync(FakeTimeProvider time) + { + Task armed = time.WaitForTimersAsync(time.GetUtcNow() + ConversationSession.TurnCompletionTimeout, 1); + _ = await Task.WhenAny(armed, Task.Delay(TimeSpan.FromSeconds(1), Ct)); + time.Advance(PastTheBound); + } + + private static async Task> TurnCompletedIndexesAsync(ResponsesHost host, string conversation) + { + QueuedAuditSink queue = host.Services.GetRequiredService(); + await queue.FlushAsync(Ct); + InMemoryAuditSink audit = Assert.IsType(queue.Store); + return [.. audit.EventsOf(conversation).Where(item => item.Kind == AuditEventKind.TurnCompleted).Select(item => item.TurnIndex)]; + } + + private static string ResponseId(List events) + { + return events[0]["response"]!["id"]!.GetValue(); + } + + private static List Parse(List events) + { + return [.. events.Select(raw => JsonNode.Parse(raw)!.AsObject())]; + } + + private static string? Type(JsonObject frame) + { + return frame["type"]?.GetValue(); + } + + /// Hands the host the store the test holds. + private sealed class HoldingStoreAdapter(HoldingStore store) : IConversationStoreAdapter + { + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(store); + } + } + + /// Holds one append naming a given turn, by its order among that turn's appends, until released. + private sealed class HoldingStore(int holdAppendOfTurn, int holdAppendNumber) : DelegatingConversationStore(new InMemoryConversationStore()) + { + private readonly TaskCompletionSource _held = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private int _appends; + + public Task Held => _held.Task; + + public void Release() + { + _ = _release.TrySetResult(); + } + + // Grants every mark, as a lapsed lease would, so two racing turns both reach the store's turn check. + public override ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(true); + } + + public override async ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + if (messages.Any(message => message.TurnIndex == holdAppendOfTurn) + && Interlocked.Increment(ref _appends) == holdAppendNumber) + { + _ = _held.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + } + + return await base.AppendAsync(conversationId, messages, state, cancellationToken); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTests.cs index da69be58..8372d477 100644 --- a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTests.cs @@ -1,8 +1,10 @@ using System.Net; -using System.Text.Json; +using System.Text; using System.Text.Json.Nodes; +using AgentCore.Application.Ports; using AgentCore.AspNetCore.Tests.Fakes; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace AgentCore.AspNetCore.Tests.Endpoints @@ -119,6 +121,108 @@ public async Task SecondTurnStreams_StillContinuesStage() Assert.Contains("answer two", text, StringComparison.Ordinal); } + [Fact] + public async Task SecondTurnStreams_NamesTheStageItSpeaksInOnTheStageHeader() + { + using FragmentingChatClient reply = new("answer one", "answer two"); + await using ResponsesHost host = await ResponsesHost.StartAsync(TwoStagesYaml, reply); + + using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "first question" }"""); + string convId = (await ResponsesHost.ReadJsonAsync(first)).ContinuationId(); + + using HttpResponseMessage second = await host.PostAsync( + $$"""{ "stream": true, "conversation": "{{convId}}", "input": "second question" }"""); + + Assert.Equal("followup", Assert.Single(second.Headers.GetValues("X-AgentCore-Stage"))); + Assert.Contains("answer two", await ResponsesHost.ReadTextAsync(second), StringComparison.Ordinal); + } + + [Fact] + public async Task StreamedReply_CarriesTheCommittedReplyId_EqualToTheStoredRow() + { + // Design section 6, step E5: TurnCommittedContent passes the seam filter, and the text + // endpoint reads the reply id off it rather than off a read-after-stream convenience copy. + using FragmentingChatClient reply = new("answer one"); + await using ResponsesHost host = await ResponsesHost.StartAsync(TwoStagesYaml, reply); + + using HttpResponseMessage response = await host.PostAsync( + /*lang=json,strict*/ + """{ "stream": true, "conversation": "conv-e5", "input": "first question", "agentcore": { "message_id": "u1" } }"""); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + List events = await ResponsesHost.ReadEventsAsync(response); + + JsonObject committed = events + .Select(raw => JsonNode.Parse(raw)!.AsObject()) + .Single(frame => frame.ContainsKey("agentcore_message_committed"))["agentcore_message_committed"]! + .AsObject(); + + string replyId = committed["reply_message_id"]!.GetValue(); + + IConversationStore store = host.Services.GetRequiredService(); + IReadOnlyList rows = await store + .ReadForSessionAsync("conv-e5", TestContext.Current.CancellationToken); + + Assert.Equal(replyId, rows[^1].MessageId); + } + + [Fact] + public async Task StreamAborted_ByTheHost_StillFilesTheSession() + { + // Design section 6, step E5: the layer commits the words on a host cancel (E4). Resuming by + // this response id finds its conversation only if ResponsesTurn.FileAsync runs too, so filing + // runs from a finally, on a token that survives the abort. + StallingChatClient reply = new(); + await using ResponsesHost host = await ResponsesHost.StartAsync(TwoStagesYaml, reply); + + using HttpResponseMessage response = await host.PostAsync( + /*lang=json,strict*/ + """{ "stream": true, "conversation": "conv-e5-abort", "input": "first question" }"""); + await reply.WaitUntilStreamingAsync(); + string responseId = await ReadFirstEventResponseIdAsync(response); + + // Tears down the connection mid-turn: the model is still stalled, so this is the host + // cancelling the request, not the reply finishing. + response.Dispose(); + + IConversationStore store = host.Services.GetRequiredService(); + await Poll.UntilAsync(() => store + .ReadForSessionAsync("conv-e5-abort", TestContext.Current.CancellationToken) + .AsTask() + .GetAwaiter() + .GetResult() + .Any(row => row.Content.Role == ChatRole.User && row.Content.Text == "first question")); + + IReadOnlyList rows = await store + .ReadForSessionAsync("conv-e5-abort", TestContext.Current.CancellationToken); + Assert.Contains(rows, row => row.Content.Role == ChatRole.User && row.Content.Text == "first question"); + + await Poll.UntilAsync(() => store + .FindContinuationAsync(responseId, TestContext.Current.CancellationToken) + .AsTask() + .GetAwaiter() + .GetResult() == "conv-e5-abort"); + } + + /// Reads the response id off the first SSE frame, before the stream ever finishes. + private static async Task ReadFirstEventResponseIdAsync(HttpResponseMessage response) + { + await using Stream body = await response.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken); + using StreamReader reader = new(body, Encoding.UTF8); + + while (await reader.ReadLineAsync(TestContext.Current.CancellationToken) is { } line) + { + if (line.StartsWith("data: ", StringComparison.Ordinal) + && JsonNode.Parse(line["data: ".Length..])!.AsObject() is { } frame + && frame["response"]?["id"]?.GetValue() is { } id) + { + return id; + } + } + + throw new InvalidOperationException("The stream ended before any event carried a response id."); + } + [Fact] public async Task UnknownConversation_StartsTheConversationUnderThatId() { @@ -234,83 +338,6 @@ public async Task TerminalTurn_SignalsTerminalAndRefusesTheNext() Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } - [Fact] - public async Task GatedToolCall_AnswersWholeWithTheRequestInMetadata() - { - int sent = 0; - await using ResponsesHost host = await ResponsesHost.StartAsync( - ApprovalYaml, - new GatedToolCallingChatClient(), - configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => sent++)))); - - using HttpResponseMessage response = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); - JsonNode body = await ResponsesHost.ReadJsonAsync(response); - - Assert.Equal(string.Empty, body.OutputText()); - string approvals = body["metadata"]!["approvals"]!.GetValue(); - Assert.Contains("send_email", approvals, StringComparison.Ordinal); - Assert.Equal(0, sent); - } - - [Fact] - public async Task ApprovalAnswer_RunsTheToolAndReplies() - { - int sent = 0; - await using ResponsesHost host = await ResponsesHost.StartAsync( - ApprovalYaml, - new GatedToolCallingChatClient(), - configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => sent++)))); - - using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); - JsonNode asked = await ResponsesHost.ReadJsonAsync(first); - string convId = asked.ContinuationId(); - string? requestId = JsonDocument.Parse( - asked["metadata"]!["approvals"]!.GetValue()) - .RootElement[0].GetProperty("request_id").GetString(); - - using HttpResponseMessage second = await host.PostAsync( - $$"""{ "stream": false, "conversation": "{{convId}}", "input": [], "agentcore": { "approval": { "request_id": "{{requestId}}", "approved": true } } }"""); - JsonNode replied = await ResponsesHost.ReadJsonAsync(second); - - Assert.Equal(HttpStatusCode.OK, second.StatusCode); - Assert.Equal(1, sent); - Assert.Contains("done.", replied.OutputText(), StringComparison.Ordinal); - } - - [Fact] - public async Task ApprovalAnswerForARequestNothingAsked_IsConflict() - { - await using ResponsesHost host = await ResponsesHost.StartAsync( - ApprovalYaml, - new GatedToolCallingChatClient(), - configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { })))); - - using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); - string convId = (await ResponsesHost.ReadJsonAsync(first)).ContinuationId(); - - using HttpResponseMessage second = await host.PostAsync( - $$"""{ "stream": false, "conversation": "{{convId}}", "input": [], "agentcore": { "approval": { "request_id": "req-nothing-asked", "approved": true } } }"""); - - Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); - } - - [Fact] - public async Task ApprovalAnswerWithWords_IsBadRequest() - { - await using ResponsesHost host = await ResponsesHost.StartAsync( - ApprovalYaml, - new GatedToolCallingChatClient(), - configure: options => options.AddToolSource(_ => new GatedSource(() => GatedSendEmail(() => { })))); - - using HttpResponseMessage first = await host.PostAsync(/*lang=json,strict*/ """{ "stream": false, "input": "send it" }"""); - string convId = (await ResponsesHost.ReadJsonAsync(first)).ContinuationId(); - - using HttpResponseMessage second = await host.PostAsync( - $$"""{ "stream": false, "conversation": "{{convId}}", "input": "send it", "agentcore": { "approval": { "request_id": "req-1", "approved": true } } }"""); - - Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); - } - [Fact] public async Task EditAtAnEarlierMessage_WithdrawsTheWordsAfterIt() { @@ -360,127 +387,5 @@ public async Task EditAtAnEarlierMessage_WithdrawsTheWordsAfterIt() llm: - { kind: openai, model: gpt-4.1-mini, as: reply } """; - - private const string ApprovalYaml = - """ - apiVersion: agentcore/v1 - agents: - defaults: - model: { ref: reply } - items: - - { id: greeter, instructions: "send the mail", tools: [ send_email ] } - providers: - conversation: { kind: telnyx-relay } - speech: - stt: { kind: telnyx-relay } - tts: { kind: telnyx-relay } - llm: - - { kind: openai, model: gpt-4.1-mini, as: reply } - entries: - main: - agent: greeter - """; - - private static ApprovalRequiredAIFunction GatedSendEmail(Action onSend) - { - return new(AIFunctionFactory.Create( - (string to) => - { - onSend(); - return "sent"; - }, - "send_email", - "Send an email.")); - } - - /// A tool source that serves one gated id, standing in for an approval-required host tool. - private sealed class GatedSource(Func build) - : Application.Ports.IToolSource - { - public ValueTask> ProvideAsync( - Application.Tools.Registry.ToolSourceContext context, CancellationToken cancellationToken = default) - { - return ValueTask.FromResult>( - [new Application.Tools.Registry.ToolRegistration("send_email", "Send an email.", build)]); - } - } - - /// Calls the first tool it is offered, once, then answers in words. - private sealed class GatedToolCallingChatClient : IChatClient - { - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.Yield(); - - bool alreadyCalled = messages.Any(message => message.Contents.OfType().Any()); - - if (!alreadyCalled && options?.Tools?.OfType().FirstOrDefault() is { } tool) - { - yield return new ChatResponseUpdate( - ChatRole.Assistant, - [new FunctionCallContent( - "conversation_1", - tool.Name, - new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" })]); - yield break; - } - - yield return new ChatResponseUpdate(ChatRole.Assistant, "done."); - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - List updates = []; - await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) - .ConfigureAwait(false)) - { - updates.Add(update); - } - - return updates.ToChatResponse(); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - } - - public void Dispose() - { - } - } - - } - - /// Reads what one Responses answer carries. - internal static class ResponsesAnswer - { - /// Reads the assistant text of one answer. - /// The answer. - /// Every output text, joined. - internal static string OutputText(this JsonNode body) - { - return string.Join("", body["output"]?.AsArray() - .SelectMany(o => o?["content"]?.AsArray() ?? []) - .Select(c => c?["text"]?.GetValue() ?? "") ?? []); - } - - /// Reads the id a later turn hangs off: the conversation, or the response. - /// The answer. - /// The continuation id. - internal static string ContinuationId(this JsonNode body) - { - return body["conversation"] is JsonObject conversation - && conversation["id"]?.GetValue() is { Length: > 0 } conversationId - ? conversationId - : body["id"]?.GetValue() - ?? throw new InvalidOperationException("Answer carries no continuation id."); - } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs new file mode 100644 index 00000000..2c671be9 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Endpoints/ResponsesTurnConflictTests.cs @@ -0,0 +1,216 @@ +using System.Net; +using System.Text.Json.Nodes; +using AgentCore.Application.Audit; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Endpoints; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Endpoints +{ + /// + /// Two requests on two hosts over one store resume the same saved conversation at once, and the busy mark holds + /// neither back (its lease lapsed). Each host builds its own session from the stored envelope, so both run turn 1; + /// the store keeps the first commit and refuses the second (owner ruling 2026-09-24, "refuse at save"). The refused + /// caller is told, and nothing of its turn is kept. One host shares one live session, so the race needs two. + /// + public sealed class ResponsesTurnConflictTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + conversations: { kind: test } + entries: + main: + agent: greeter + """; + + [Fact] + public async Task TwoRequestsOnOneTurn_NotStreamed_OneIsConflict_AndOnlyTheOtherIsKept() + { + // Arrange + RendezvousChatClient model = new(parties: 2); + LapsedMarkStore store = new(); + await using ResponsesHost hostA = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); + await using ResponsesHost hostB = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); + string conversation = await OpenAsync(hostA); + + // Act + (HttpResponseMessage first, HttpResponseMessage second) = await RaceAsync(hostA, hostB, conversation, stream: false); + using HttpResponseMessage a = first; + using HttpResponseMessage b = second; + + // Assert + HttpResponseMessage won = Assert.Single([a, b], response => response.StatusCode == HttpStatusCode.OK); + HttpResponseMessage lost = Assert.Single([a, b], response => response.StatusCode == HttpStatusCode.Conflict); + JsonNode error = (await ResponsesHost.ReadJsonAsync(lost))["error"]!; + Assert.Equal(ResponsesTurnConflict.Code, error["code"]!.GetValue()); + Assert.Equal(ResponsesTurnConflict.ConflictMessage, error["message"]!.GetValue()); + + JsonNode answer = await ResponsesHost.ReadJsonAsync(won); + string winner = answer.OutputText()["reply to ".Length..]; + ResponsesHost loserHost = ReferenceEquals(lost, a) ? hostA : hostB; + await AssertOnlyTheWinnerIsKeptAsync([hostA, hostB], loserHost, model, conversation, winner, answer["id"]!.GetValue()); + } + + [Fact] + public async Task TwoRequestsOnOneTurn_Streamed_OneEndsWithTheErrorEvent_AndOnlyTheOtherIsKept() + { + // Arrange + RendezvousChatClient model = new(parties: 2); + LapsedMarkStore store = new(); + await using ResponsesHost hostA = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); + await using ResponsesHost hostB = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(store)); + string conversation = await OpenAsync(hostA); + + // Act + (HttpResponseMessage first, HttpResponseMessage second) = await RaceAsync(hostA, hostB, conversation, stream: true); + using HttpResponseMessage a = first; + using HttpResponseMessage b = second; + List eventsA = Parse(await ResponsesHost.ReadEventsAsync(a)); + List eventsB = Parse(await ResponsesHost.ReadEventsAsync(b)); + + // Assert + Assert.Equal(HttpStatusCode.OK, a.StatusCode); + Assert.Equal(HttpStatusCode.OK, b.StatusCode); + List won = Assert.Single([eventsA, eventsB], events => Type(events[^1]) == "response.completed"); + List lost = Assert.Single([eventsA, eventsB], events => Type(events[^1]) == "error"); + ResponsesHost loserHost = ReferenceEquals(lost, eventsA) ? hostA : hostB; + + Assert.Equal(ResponsesTurnConflict.Code, lost[^1]["code"]!.GetValue()); + Assert.Equal(ResponsesTurnConflict.ConflictMessage, lost[^1]["message"]!.GetValue()); + Assert.DoesNotContain(lost, frame => Type(frame) == "response.completed"); + Assert.DoesNotContain(lost, frame => frame.ContainsKey(TurnStreamPart.MessageCommitted)); + Assert.Contains(won, frame => frame.ContainsKey(TurnStreamPart.MessageCommitted)); + + // The refused session is never filed, not even under the response id only its own stream named. + string lostResponseId = lost[0]["response"]!["id"]!.GetValue(); + Assert.Null(await store.FindContinuationAsync(lostResponseId, TestContext.Current.CancellationToken)); + + string winner = string.Concat(ResponsesHost.TextDeltas(won.Select(frame => frame.ToJsonString())))["reply to ".Length..]; + string responseId = won[0]["response"]!["id"]!.GetValue(); + await AssertOnlyTheWinnerIsKeptAsync([hostA, hostB], loserHost, model, conversation, winner, responseId); + } + + // One host holds one live session per conversation, so two requests sent to it at once take turns. + [Fact] + public async Task TwoRequestsAtOnceOnOneHost_BothRun_OneAfterTheOther() + { + // Arrange + RendezvousChatClient model = new(parties: 1); + await using ResponsesHost host = await ResponsesHost.StartAsync(Yaml, model, options => options.UseConversationStores(new LapsedMarkStore())); + string conversation = await OpenAsync(host); + + // Act + (HttpResponseMessage first, HttpResponseMessage second) = await RaceAsync(host, host, conversation, stream: false); + using HttpResponseMessage a = first; + using HttpResponseMessage b = second; + + // Assert + Assert.Equal((HttpStatusCode.OK, HttpStatusCode.OK), (a.StatusCode, b.StatusCode)); + string[] turns = + [ + (await ResponsesHost.ReadJsonAsync(a))["metadata"]!["turn_index"]!.GetValue(), + (await ResponsesHost.ReadJsonAsync(b))["metadata"]!["turn_index"]!.GetValue(), + ]; + Assert.Equal(["1", "2"], turns.Order()); + + List rows = await StoredRows.SettleAsync(host.Services, conversation, 6); + List spoken = [.. rows.Where(row => row.Content.Role.Value == "user").Select(row => row.Content.Text)]; + Assert.Equal("open", spoken[0]); + Assert.Equal(["race A", "race B"], spoken.Skip(1).Order()); + Assert.Equal([0, 1, 2], rows.Where(row => row.Content.Role.Value == "user").Select(row => row.TurnIndex)); + } + + private static async Task OpenAsync(ResponsesHost host) + { + string conversation = "conv_" + Guid.NewGuid().ToString("N"); + using HttpResponseMessage open = await host.PostAsync( + /*lang=json,strict*/ $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "open" }"""); + Assert.Equal(HttpStatusCode.OK, open.StatusCode); + return conversation; + } + + private static async Task<(HttpResponseMessage First, HttpResponseMessage Second)> RaceAsync( + ResponsesHost hostA, ResponsesHost hostB, string conversation, bool stream) + { + string flag = stream ? "true" : "false"; + Task first = hostA.PostAsync( + /*lang=json,strict*/ $$"""{ "stream": {{flag}}, "conversation": "{{conversation}}", "input": "race A", "agentcore": { "message_id": "user-a" } }"""); + Task second = hostB.PostAsync( + /*lang=json,strict*/ $$"""{ "stream": {{flag}}, "conversation": "{{conversation}}", "input": "race B", "agentcore": { "message_id": "user-b" } }"""); + + return (await first, await second); + } + + /// + /// Checks the store, the continuation and the audit chain hold the winning turn and nothing of the refused + /// one: its rows, its envelope and its turn.completed are all absent, and a turn.refused names it. + /// The next message goes to the host that lost, whose live session must catch up on the winner's words. + /// + private static async Task AssertOnlyTheWinnerIsKeptAsync( + ResponsesHost[] hosts, ResponsesHost loserHost, RendezvousChatClient model, string conversation, string winner, string winnerResponseId) + { + string loser = winner == "race A" ? "race B" : "race A"; + + string? owner = await loserHost.Services.GetRequiredService() + .FindContinuationAsync(winnerResponseId, TestContext.Current.CancellationToken); + Assert.Equal(conversation, owner); + + List rows = await StoredRows.SettleAsync(loserHost.Services, conversation, 4); + Assert.Equal( + [(0, "open"), (0, "reply to open"), (1, winner), (1, "reply to " + winner)], + rows.Select(row => (row.TurnIndex, row.Content.Text))); + + using HttpResponseMessage next = await loserHost.PostAsync( + /*lang=json,strict*/ $$"""{ "stream": false, "conversation": "{{conversation}}", "input": "next" }"""); + JsonNode answer = await ResponsesHost.ReadJsonAsync(next); + Assert.Equal("2", answer["metadata"]!["turn_index"]!.GetValue()); + IReadOnlyList heard = model.Requests.Last(); + Assert.Contains(winner, heard); + Assert.DoesNotContain(loser, heard); + + List events = []; + foreach (ResponsesHost host in hosts) + { + QueuedAuditSink queue = host.Services.GetRequiredService(); + await queue.FlushAsync(TestContext.Current.CancellationToken); + events.AddRange(Assert.IsType(queue.Store).EventsOf(conversation)); + } + + Assert.Equal( + new int?[] { 0, 1, 2 }, + events.Where(item => item.Kind == AuditEventKind.TurnCompleted).Select(item => item.TurnIndex).Order()); + + // The refusal leaves its own row, which is all that is left of it when its client has gone. + AuditEvent refusal = Assert.Single(events, item => item.Kind == AuditEventKind.TurnRefused); + Assert.Equal((1, "conflict"), (refusal.TurnIndex, refusal.Payload[AuditPayloadKeys.RefusedReason])); + } + + private static List Parse(List events) + { + return [.. events.Select(raw => JsonNode.Parse(raw)!.AsObject())]; + } + + private static string? Type(JsonObject frame) + { + return frame["type"]?.GetValue(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs new file mode 100644 index 00000000..d0e099fd --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/AgentStateLog.cs @@ -0,0 +1,52 @@ +using AgentCore.AspNetCore.Voice; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Every agent state change a raises, in order. + internal sealed class AgentStateLog + { + private readonly Lock _gate = new(); + private readonly List _changes = []; + private readonly List<(int Count, TaskCompletionSource Reached)> _waiters = []; + + public AgentStateLog(VoiceSession session) + { + session.AgentStateChanged += change => + { + lock (_gate) + { + _changes.Add(change); + _ = _waiters.RemoveAll(waiter => waiter.Count <= _changes.Count && waiter.Reached.TrySetResult()); + } + }; + } + + /// Gets each change's new state, in order. + public IReadOnlyList NewStates + { + get { lock (_gate) { return [.. _changes.Select(change => change.NewState)]; } } + } + + /// Gets each change as an old and a new state, in order. + public IReadOnlyList<(AgentState Old, AgentState New)> Transitions + { + get { lock (_gate) { return [.. _changes.Select(change => (change.OldState, change.NewState))]; } } + } + + /// Waits until at least changes were raised. + public Task WaitForCountAsync(int count) + { + lock (_gate) + { + if (_changes.Count >= count) + { + return Task.CompletedTask; + } + + TaskCompletionSource reached = new(TaskCreationOptions.RunContinuationsAsynchronously); + _waiters.Add((count, reached)); + return reached.Task; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/CapturedTimer.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/CapturedTimer.cs new file mode 100644 index 00000000..2c56cae4 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/CapturedTimer.cs @@ -0,0 +1,30 @@ +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// One timer a created. + internal sealed class CapturedTimer(TimerCallback callback, object? state) : ITimer + { + public bool Disposed { get; private set; } + + /// Runs the callback now, disposed or not. + public void Fire() + { + callback(state); + } + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + return !Disposed; + } + + public void Dispose() + { + Disposed = true; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/CapturingTimeProvider.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/CapturingTimeProvider.cs new file mode 100644 index 00000000..aee67d4b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/CapturingTimeProvider.cs @@ -0,0 +1,27 @@ +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// A clock that never fires on its own: a test calls a captured timer's callback itself, even + /// after that timer was disposed, as .NET may run a callback it dispatched just before the dispose. + internal sealed class CapturingTimeProvider : TimeProvider + { + private readonly Lock _gate = new(); + private readonly List _timers = []; + + /// Gets every timer created so far, oldest first. + public IReadOnlyList Timers + { + get { lock (_gate) { return [.. _timers]; } } + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + CapturedTimer timer = new(callback, state); + lock (_gate) + { + _timers.Add(timer); + } + + return timer; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs new file mode 100644 index 00000000..01407b92 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/CountingConversationStore.cs @@ -0,0 +1,38 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// A conversation store that counts every call to : the one read + /// that returns the whole transcript a session opens on. Everything else delegates to a real + /// in-memory store, so a test can seed rows and file continuations exactly as a live store would. + /// + internal sealed class CountingConversationStore() + : DelegatingConversationStore(new InMemoryConversationStore()), IConversationStoreAdapter + { + private int _fullReads; + + public string Kind => "test"; + + /// Gets how many times was called. + public int FullReads => _fullReads; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override async ValueTask> ReadForSessionAsync( + string conversationId, CancellationToken cancellationToken = default) + { + _ = Interlocked.Increment(ref _fullReads); + return await base.ReadForSessionAsync(conversationId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs index 6b96b7e2..e41bafb0 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationInput.cs @@ -1,6 +1,5 @@ using System.Runtime.CompilerServices; -using AgentCore.Application.Ports; -using AgentCore.Application.Conversation; +using AgentCore.AspNetCore.Voice; namespace AgentCore.AspNetCore.Tests.Fakes { diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs index 45593a2f..ef99c65f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeConversationOutput.cs @@ -1,4 +1,4 @@ -using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Voice; namespace AgentCore.AspNetCore.Tests.Fakes { @@ -6,7 +6,11 @@ namespace AgentCore.AspNetCore.Tests.Fakes internal sealed class FakeConversationOutput : IConversationOutputPort { private readonly List _spoken = []; + private readonly List _log = []; + private readonly List<(int Count, TaskCompletionSource Reached)> _logWaiters = []; private readonly Lock _gate = new(); + private int _completions; + private int _stops; /// Gets every fragment this port was given, in order. public IReadOnlyList Spoken @@ -14,34 +18,76 @@ public IReadOnlyList Spoken get { lock (_gate) { return [.. _spoken]; } } } + /// Gets every call but in order: speak: and the fragment, complete, or stop. + public IReadOnlyList Log + { + get { lock (_gate) { return [.. _log]; } } + } + /// Gets how many replies were closed. - public int Completions { get; private set; } + public int Completions => Volatile.Read(ref _completions); /// Gets how many times a barge-in stopped this port. - public int Stops { get; private set; } + public int Stops => Volatile.Read(ref _stops); /// Gets how many times this port was disposed. public int Disposals { get; private set; } + /// Gets or sets what waits on after it logged a fragment, or null for nothing. + public Func? BeforeSpeakReturns { get; set; } + + /// Waits until holds at least entries. + /// How many entries to wait for. + /// A task that completes once they are there. + public Task WaitForLogAsync(int count) + { + lock (_gate) + { + if (_log.Count >= count) + { + return Task.CompletedTask; + } + + TaskCompletionSource reached = new(TaskCreationOptions.RunContinuationsAsynchronously); + _logWaiters.Add((count, reached)); + return reached.Task; + } + } + + public void BeginReply() + { + } + public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default) { lock (_gate) { _spoken.Add(fragment); + AppendLocked("speak:" + fragment); } - return ValueTask.CompletedTask; + return BeforeSpeakReturns is { } wait ? new ValueTask(wait(fragment, cancellationToken)) : ValueTask.CompletedTask; } public ValueTask CompleteAsync(CancellationToken cancellationToken = default) { - Completions++; + lock (_gate) + { + _completions++; + AppendLocked("complete"); + } + return ValueTask.CompletedTask; } public ValueTask StopAsync(CancellationToken cancellationToken = default) { - Stops++; + lock (_gate) + { + _stops++; + AppendLocked("stop"); + } + return ValueTask.CompletedTask; } @@ -50,5 +96,11 @@ public ValueTask DisposeAsync() Disposals++; return ValueTask.CompletedTask; } + + private void AppendLocked(string entry) + { + _log.Add(entry); + _ = _logWaiters.RemoveAll(waiter => waiter.Count <= _log.Count && waiter.Reached.TrySetResult()); + } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs index 85744991..4f15600c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeWebSocket.cs @@ -3,7 +3,7 @@ using System.Text; using System.Threading.Channels; using AgentCore.Application.Configuration.Parsing; -using AgentCore.AspNetCore.Conversation; +using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.Vendors.TelnyxRelay; using Microsoft.AspNetCore.Http; @@ -36,8 +36,8 @@ namespace AgentCore.AspNetCore.Tests.Fakes /// a status still reaches a test after a real socket would already have aborted. /// /// - /// A faulting send. makes the write loop throw on demand, which - /// nothing on a healthy loopback socket does. + /// A faulting send, receive or close. , and + /// make the socket throw on demand, which nothing on a healthy loopback socket does. /// /// /// @@ -47,6 +47,8 @@ namespace AgentCore.AspNetCore.Tests.Fakes /// internal sealed class FakeWebSocket : WebSocket { + private static readonly byte[] ReceiveFaultMarker = []; + private readonly Channel _inbound = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); @@ -54,14 +56,23 @@ internal sealed class FakeWebSocket : WebSocket private readonly List _sent = []; private readonly TaskCompletionSource _parked = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _released = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _sendStalled = new(TaskCreationOptions.RunContinuationsAsynchronously); private volatile bool _parkNextStateRead; private Exception? _sendFault; + private Exception? _receiveFault; + private Exception? _closeFault; + private WebSocketState? _goneOnSend; + private volatile bool _stallSends; + private bool _abortDuringClose; private WebSocketState _state = WebSocketState.Open; /// Gets a task that completes once the write loop parks inside . public Task Parked => _parked.Task; + /// Gets a task that completes once a send is stalled by . + public Task SendStalled => _sendStalled.Task; + /// Gets the status and description CloseOutputAsync was called with, or null. public (WebSocketCloseStatus Status, string? Description)? CloseSent { get; private set; } @@ -138,6 +149,25 @@ public void FailEverySend(Exception fault) _sendFault = fault; } + /// Makes the next send find the socket gone, as a managed socket does once the peer dropped or the close ran. + /// What the socket is by then: or . + public void GoneOnSend(WebSocketState state) + { + _goneOnSend = state; + } + + /// Makes every send hang until its token is cancelled, as a send stuck on a peer that stopped reading. + public void StallSends() + { + _stallSends = true; + } + + /// Makes the close find the socket aborted under it by a cancelled send, which surfaces as a cancellation. + public void AbortDuringClose() + { + _abortDuringClose = true; + } + /// Queues one inbound message, exactly as the vendor would send it. /// The frame. public void Queue(string json) @@ -145,6 +175,21 @@ public void Queue(string json) _ = _inbound.Writer.TryWrite(Encoding.UTF8.GetBytes(json)); } + /// Queues a receive that throws, after every message already queued, so the read loop faults. + /// The cause the loop takes. + public void FailReceive(Exception fault) + { + _receiveFault = fault; + _ = _inbound.Writer.TryWrite(ReceiveFaultMarker); + } + + /// Makes the close throw. + /// What CloseOutputAsync throws. + public void FailClose(Exception fault) + { + _closeFault = fault; + } + /// Queues the vendor's own close frame, which ends the read loop. public void QueueClose() { @@ -158,6 +203,11 @@ public override async ValueTask ReceiveAsync( { byte[]? message = await _inbound.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + if (ReferenceEquals(message, ReceiveFaultMarker)) + { + throw _receiveFault!; + } + if (message is null) { _state = WebSocketState.CloseReceived; @@ -189,6 +239,11 @@ public override Task SendAsync( bool endOfMessage, CancellationToken cancellationToken) { + if (_stallSends) + { + return StallAsync(cancellationToken); + } + Record(buffer.AsMemory().Span); return Task.CompletedTask; } @@ -201,6 +256,11 @@ public override ValueTask SendAsync( bool endOfMessage, CancellationToken cancellationToken) { + if (_stallSends) + { + return new ValueTask(StallAsync(cancellationToken)); + } + Record(buffer.Span); return ValueTask.CompletedTask; } @@ -214,18 +274,50 @@ private void Record(ReadOnlySpan buffer) throw fault; } + if (_goneOnSend is { } gone) + { + _state = gone; + throw new WebSocketException( + WebSocketError.InvalidState, + $"The WebSocket is in an invalid state ('{gone}') for this operation. Valid states are: 'Open, CloseReceived'"); + } + lock (_gate) { _sent.Add(Encoding.UTF8.GetString(buffer)); } } + private async Task StallAsync(CancellationToken cancellationToken) + { + _ = _sendStalled.TrySetResult(); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + finally + { + _state = WebSocketState.Aborted; + } + } + /// public override Task CloseOutputAsync( WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) { + if (_closeFault is { } fault) + { + throw fault; + } + + if (_abortDuringClose) + { + _state = WebSocketState.Aborted; + throw new OperationCanceledException("the send the close waited behind was cancelled, and it aborted the socket."); + } + CloseSent = (closeStatus, statusDescription); _state = WebSocketState.CloseSent; return Task.CompletedTask; @@ -273,169 +365,4 @@ public override void Dispose() ReleaseParkedState(); } } - - /// - /// An application lifetime a test owns, so a test can stop the host without one. - /// - /// - /// The connection links its own token to and reads that same token - /// again when it picks a close status, so this is the only seam a test needs to prove the - /// host-stopping path. - /// - internal sealed class TestHostLifetime : IHostApplicationLifetime, IDisposable - { - private readonly CancellationTokenSource _started = new(); - private readonly CancellationTokenSource _stopping = new(); - private readonly CancellationTokenSource _stopped = new(); - - public CancellationToken ApplicationStarted => _started.Token; - - public CancellationToken ApplicationStopping => _stopping.Token; - - public CancellationToken ApplicationStopped => _stopped.Token; - - public void StopApplication() - { - _stopping.Cancel(); - _stopped.Cancel(); - } - - public void Dispose() - { - _started.Dispose(); - _stopping.Dispose(); - _stopped.Dispose(); - } - } - - /// - /// One driven straight over a . - /// - /// - /// RunAsync takes the abstract , so nothing here needs Kestrel, a - /// port, or a client socket. The service provider is the real one AddAgentCore builds, so - /// the session factory, the store, and the clock behave exactly as they do on a live conversation. - /// - internal sealed class RelayConnectionHarness : IAsyncDisposable - { - private readonly ServiceProvider _services; - private readonly TestHostLifetime _lifetime; - - private RelayConnectionHarness( - ServiceProvider services, - TestHostLifetime lifetime, - FakeWebSocket socket, - Task connection) - { - _services = services; - _lifetime = lifetime; - Socket = socket; - Connection = connection; - } - - /// Gets the socket a test queues frames on and reads sends off. - public FakeWebSocket Socket { get; } - - /// Gets the container the connection resolves everything from. - /// - /// The provider is the real one AddAgentCore builds, so a test reads the live session - /// store, the audit queue, and the store behind it exactly as the connection does. It is exposed - /// rather than mirrored in properties of this class, because what one test wants out of the - /// container is no business of the next one. - /// - public IServiceProvider Services => _services; - - /// Gets the task that completes when the connection has torn itself down. - public Task Connection { get; } - - /// Starts one connection over one document and one fake model. - /// The document, as YAML. - /// The model behind every agent. - /// Anything a test adds to the logging pipeline. - /// Anything a test binds on the relay endpoint's own options. - /// - /// Anything else the test binds on the container's own options, for example the clock every - /// conversation and every connection then runs on. - /// - /// - /// Anything a test registers over what AddAgentCore registered. It runs after that conversation, - /// so a registration here is the last one and the connection resolves it. - /// - /// The running harness. - public static async Task StartAsync( - string yaml, - IChatClient reply, - Action? logging = null, - Action? relay = null, - Action? configure = null, - Action? services = null) - { - ServiceCollection collection = new(); - _ = collection.AddLogging(builder => - { - _ = builder.ClearProviders(); - logging?.Invoke(builder); - }); - - TestHostLifetime lifetime = new(); - _ = collection.AddSingleton(lifetime); - _ = collection.AddAgentCore(options => - { - options.Configuration = ConfigurationLoader.LoadYaml(yaml); - _ = options.UseChatClients(_ => new RoutingChatClientFactory(reply)); - configure?.Invoke(options); - }); - - services?.Invoke(collection); - - ServiceProvider provider = collection.BuildServiceProvider(); - - // There is no host here, so nothing else would run the boot. This is the same hook a host - // uses, in the same order, so the harness reaches a graph composed exactly as production - // composes it. - foreach (IHostedLifecycleService service in provider.GetServices().OfType()) - { - await service.StartingAsync(CancellationToken.None); - } - - DefaultHttpContext http = new() { RequestServices = provider }; - http.Request.RouteValues[ConversationEndpointRouteBuilderExtensions.EntryRouteParameter] = "main"; - - TelnyxRelayOptions options = new(); - relay?.Invoke(options); - - FakeWebSocket socket = new(); - Task connection = TelnyxRelayConnection.RunAsync(http, socket, options); - - return new RelayConnectionHarness(provider, lifetime, socket, connection); - } - /// Stops the host, which is what the EndpointUnavailable close status reports. - public void StopApplication() - { - _lifetime.StopApplication(); - } - - /// Ends the connection and releases everything behind it. - public async ValueTask DisposeAsync() - { - // A test that failed early may have left the write loop parked or the read loop waiting, so - // both are released here before anything waits on the connection task. - Socket.ReleaseParkedState(); - Socket.QueueClose(); - - try - { - await Connection.WaitAsync(TimeSpan.FromSeconds(20)).ConfigureAwait(false); - } - catch (Exception) - { - // The connection's own faults belong to whichever assertion a test already made about - // them. Disposal only has to stop waiting. - } - - Socket.Dispose(); - _lifetime.Dispose(); - await _services.DisposeAsync().ConfigureAwait(false); - } - } } diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs new file mode 100644 index 00000000..7705efb2 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/GatedToolCallingChatClient.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Calls the first tool it is offered, once, then answers in words. + internal sealed class GatedToolCallingChatClient : IChatClient + { + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + bool alreadyCalled = messages.Any(message => message.Contents.OfType().Any()); + + if (!alreadyCalled && options?.Tools?.OfType().FirstOrDefault() is { } tool) + { + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent( + "conversation_1", + tool.Name, + new Dictionary(StringComparer.Ordinal) { ["to"] = "a@b.com" })]); + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, "done."); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/HostFakes.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/HostFakes.cs index 7479d4a4..839a1441 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/HostFakes.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/HostFakes.cs @@ -15,11 +15,15 @@ namespace AgentCore.AspNetCore.Tests.Fakes internal sealed class FragmentingChatClient(params string[] replies) : IChatClient { private readonly string[] _replies = replies; + private readonly TaskCompletionSource _streamed = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _calls; /// Gets how many requests this client answered. public int Calls => Volatile.Read(ref _calls); + /// Gets a task that completes once the reader has taken the last update of a streamed reply. + public Task Streamed => _streamed.Task; + /// Gets the messages of the most recent request, oldest first, or null before any conversation. /// /// A test reads this to prove which text actually reached the model — for example that an @@ -49,6 +53,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync( MessageId = responseId, }; } + + _ = _streamed.TrySetResult(); } public async Task GetResponseAsync( @@ -197,10 +203,8 @@ private static IEnumerable Fragments(string reply) /// before it says a word. /// /// - /// This is the shape a held prompt produces on a real conversation. Turn one pauses long enough for a - /// second final prompt to be held, then finishes streaming; RunPendingPrompt starts turn two - /// inside turn one's own finally; and the vendor is still speaking turn one while turn two - /// has produced nothing at all. No other fake here can hold the second turn apart from the first. + /// The vendor paces the audio, so it can still be speaking turn one while turn two has produced nothing + /// the caller hears. No other fake here can hold the second turn apart from the first. /// internal sealed class HeldPromptChatClient(string first, string second) : IChatClient { @@ -216,20 +220,10 @@ internal sealed class HeldPromptChatClient(string first, string second) : IChatC /// Gets whether the second turn opens with one update the relay can never speak. /// - /// - /// ConversationSession calls a run audible at its first piece of content, and content is not - /// the same thing as a word. A tool call, a tool result, and a line of reasoning all count, and - /// none of them carries text, so none of them ever reaches the vendor as a text frame. Set - /// this, and turn two yields exactly one such update — a , the - /// shape a reasoning model really does stream before its answer — and only then blocks. That is - /// the one window in which the core believes turn two is audible while the connection's own - /// _spokenTurnId still names turn one, which is the turn Telnyx is still speaking. - /// - /// - /// Left , turn two says nothing whatever before it blocks, which is the - /// window AnInterruptWhileAHeldTurnHasSaidNothing_LeavesThatTurnFreeToSpeak already covers - /// and which both sides of the seam already agree about on their own. - /// + /// Content is not the same thing as a word: a tool call, a tool result, and a line of reasoning carry + /// no text, so none of them reaches the vendor as a text frame. Set this, and turn two yields + /// exactly one such update — a , the shape a reasoning model really + /// does stream before its answer — and only then blocks. /// public bool SecondTurnOpensWithUnspokenContent { get; init; } @@ -267,10 +261,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { // Yielded before SecondTurnStarted is set, and the line after a yield only runs once // the consumer comes back for the next update. Waiting on that signal therefore - // proves this update has already travelled the whole way through ConversationSession — which - // is where it raises the audible flag this fake exists to raise — and through the - // connection's own update loop, which reads no text on it and so leaves - // _spokenTurnId naming turn one. + // proves this update has already travelled the whole way through ConversationSession and + // the voice layer, which reads no text on it. yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextReasoningContent("thinking")]) { ResponseId = responseId, diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/LapsedMarkStore.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/LapsedMarkStore.cs new file mode 100644 index 00000000..edc3eb56 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/LapsedMarkStore.cs @@ -0,0 +1,29 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.TestSupport; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// An in-memory store whose busy mark never holds anyone back, as when a holder's lease lapsed under it. Two turns + /// through it race to the store's turn check, the backstop behind the mark. It is also its own adapter, of kind + /// test. + /// + internal sealed class LapsedMarkStore() : DelegatingConversationStore(new InMemoryConversationStore()), IConversationStoreAdapter + { + public string Kind => "test"; + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(true); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs new file mode 100644 index 00000000..88ac236e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/LatencyReadings.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.Metrics; +using AgentCore.Application.Diagnostics; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Collects every measurement this library's histograms take while it is alive. + internal sealed class LatencyReadings : IDisposable + { + private readonly MeterListener _listener = new(); + private readonly List<(string Instrument, double Value)> _readings = []; + private readonly Lock _gate = new(); + + public LatencyReadings() + { + _listener.InstrumentPublished = (instrument, active) => + { + if (string.Equals(instrument.Meter.Name, AgentCoreTelemetry.MeterName, StringComparison.Ordinal)) + { + active.EnableMeasurementEvents(instrument); + } + }; + + _listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + lock (_gate) + { + _readings.Add((instrument.Name, value)); + } + }); + + _listener.Start(); + } + + /// Gets what one histogram was told, in order, rounded to the millisecond. + /// The metric name. + /// The measurements, in seconds. + public IReadOnlyList Of(string instrument) + { + lock (_gate) + { + return [.. _readings + .Where(reading => string.Equals(reading.Instrument, instrument, StringComparison.Ordinal)) + .Select(reading => Math.Round(reading.Value, 3))]; + } + } + + public void Dispose() => _listener.Dispose(); + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/Poll.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/Poll.cs new file mode 100644 index 00000000..a293c4ab --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/Poll.cs @@ -0,0 +1,15 @@ +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Waits for a state no event announces. The test's own timeout bounds it. + internal static class Poll + { + /// Waits until holds. + public static async Task UntilAsync(Func condition) + { + while (!condition()) + { + await Task.Delay(1).ConfigureAwait(false); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs new file mode 100644 index 00000000..023e3e63 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RelayConnectionHarness.cs @@ -0,0 +1,136 @@ +using AgentCore.TestSupport; +using System.Net.WebSockets; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.DependencyInjection; +using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// One driven straight over a . + /// + internal sealed class RelayConnectionHarness : IAsyncDisposable + { + private readonly ServiceProvider _services; + private readonly TestHostLifetime _lifetime; + + private RelayConnectionHarness( + ServiceProvider services, + TestHostLifetime lifetime, + FakeWebSocket socket, + Task connection) + { + _services = services; + _lifetime = lifetime; + Socket = socket; + Connection = connection; + } + + /// Gets the socket a test queues frames on and reads sends off. + public FakeWebSocket Socket { get; } + + /// Gets the container the connection resolves everything from. + public IServiceProvider Services => _services; + + /// Gets the task that completes when the connection has torn itself down. + public Task Connection { get; } + + /// Starts one connection over one document and one fake model. + /// The document, as YAML. + /// The model behind every agent. + /// Anything a test adds to the logging pipeline. + /// Anything a test binds on the relay endpoint's own options. + /// + /// Anything else the test binds on the container's own options, for example the clock every + /// conversation and every connection then runs on. + /// + /// + /// Anything a test registers over what AddAgentCore registered. It runs after that conversation, + /// so a registration here is the last one and the connection resolves it. + /// + /// The entry the route names, as reads it. + /// The running harness. + public static async Task StartAsync( + string yaml, + IChatClient reply, + Action? logging = null, + Action? relay = null, + Action? configure = null, + Action? services = null, + string entry = SingleEntrySessionFactories.MainEntry) + { + ServiceCollection collection = new(); + _ = collection.AddLogging(builder => + { + _ = builder.ClearProviders(); + logging?.Invoke(builder); + }); + + TestHostLifetime lifetime = new(); + _ = collection.AddSingleton(lifetime); + _ = collection.AddAgentCore(options => + { + options.Configuration = ConfigurationLoader.LoadYaml(yaml); + _ = options.UseChatClients(_ => new RoutingChatClientFactory(reply)); + configure?.Invoke(options); + }); + + services?.Invoke(collection); + + ServiceProvider provider = collection.BuildServiceProvider(); + + // There is no host here, so nothing else would run the boot. This is the same hook a host + // uses, in the same order, so the harness reaches a graph composed exactly as production + // composes it. + foreach (IHostedLifecycleService service in provider.GetServices().OfType()) + { + await service.StartingAsync(CancellationToken.None); + } + + DefaultHttpContext http = new() { RequestServices = provider }; + http.Request.RouteValues[ConversationEndpointRouteBuilderExtensions.EntryRouteParameter] = entry; + + TelnyxRelayOptions options = new(); + relay?.Invoke(options); + + FakeWebSocket socket = new(); + Task connection = TelnyxRelayConnection.RunAsync(http, socket, options); + + return new RelayConnectionHarness(provider, lifetime, socket, connection); + } + /// Stops the host, which is what the EndpointUnavailable close status reports. + public void StopApplication() + { + _lifetime.StopApplication(); + } + + /// Ends the connection and releases everything behind it. + public async ValueTask DisposeAsync() + { + // A test that failed early may have left the write loop parked or the read loop waiting, so + // both are released here before anything waits on the connection task. + Socket.ReleaseParkedState(); + Socket.QueueClose(); + + try + { + await Connection.WaitAsync(TimeSpan.FromSeconds(20)).ConfigureAwait(false); + } + catch (Exception) + { + // The connection's own faults belong to whichever assertion a test already made about + // them. Disposal only has to stop waiting. + } + + Socket.Dispose(); + _lifetime.Dispose(); + await _services.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/RendezvousChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/RendezvousChatClient.cs new file mode 100644 index 00000000..e87f3855 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/RendezvousChatClient.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// A model that answers "reply to" the caller's last words. A request whose last words start with + /// answers only once such requests are waiting, so every racing + /// request has read the conversation before any of them commits. + /// + /// How many racing requests answer together. + internal sealed class RendezvousChatClient(int parties) : IChatClient + { + /// The words that make a request wait for the others. + public const string RacePrefix = "race"; + + private readonly TaskCompletionSource _allArrived = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private int _arrived; + + /// Gets every request the model was sent, each as the texts of its messages, oldest first. + public ConcurrentQueue> Requests { get; } = new(); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List sent = [.. messages]; + Requests.Enqueue([.. sent.Select(message => message.Text)]); + string last = sent.LastOrDefault(message => message.Role == ChatRole.User)?.Text ?? string.Empty; + + if (last.StartsWith(RacePrefix, StringComparison.Ordinal)) + { + if (Interlocked.Increment(ref _arrived) >= parties) + { + _ = _allArrived.TrySetResult(); + } + + await _allArrived.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + string id = Guid.NewGuid().ToString("N"); + yield return new ChatResponseUpdate(ChatRole.Assistant, "reply to " + last) { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs new file mode 100644 index 00000000..0a07c21e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoicePort.cs @@ -0,0 +1,150 @@ +using AgentCore.Application.Runtime; +using AgentCore.Application.Ports; +using AgentCore.Domain; +using AgentCore.Domain.Knowledge; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// A conversation whose turns the test streams by hand (), the C# stand-in + /// for LiveKit's tests/fake_llm.py. It refuses a second turn while one runs, as the engine does, and + /// records every cut and recut it was told about. + /// + internal sealed class ScriptedVoicePort : IConversationPort + { + private readonly Lock _gate = new(); + private readonly List _turns = []; + private readonly List<(int TurnIndex, TurnCut Cut)> _cuts = []; + private readonly List<(int TurnIndex, TurnCut Cut)> _recuts = []; + private ScriptedVoiceTurn? _running; + private int _started; + + public string ConversationId => "conversation-scripted-voice"; + + public string Stage => string.Empty; + + public bool IsComplete => false; + + public KnowledgeScope? Scope { get; set; } + + public TurnResult? LastTurn { get; set; } + + /// Gets or sets whether a cut of the running turn ends it, as the engine's cancellation does. + public bool EndTurnOnCut { get; set; } = true; + + /// Gets every cut this conversation was told about, in order. Turn indexes count from zero. + public IReadOnlyList<(int TurnIndex, TurnCut Cut)> Cuts + { + get { lock (_gate) { return [.. _cuts]; } } + } + + /// Gets every recut this conversation was told about, in order. + public IReadOnlyList<(int TurnIndex, TurnCut Cut)> Recuts + { + get { lock (_gate) { return [.. _recuts]; } } + } + + /// Gets a turn by the order it runs in, from one. It may be scripted before it starts. Its index is one less. + public ScriptedVoiceTurn Turn(int ordinal) + { + lock (_gate) + { + return TurnLocked(ordinal); + } + } + + public Task RunTurnAsync(string userInput, CancellationToken cancellationToken = default) + { + throw new NotSupportedException("the voice layer only ever starts a turn."); + } + + public Task RunTurnMessageAsync(ChatMessage userInput, CancellationToken cancellationToken) + { + throw new NotSupportedException("the voice layer only ever starts a turn."); + } + + public IAsyncEnumerable RunTurnStreamingAsync( + string userInput, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException("the voice layer only ever starts a turn."); + } + + public IAsyncEnumerable RunTurnMessageStreamingAsync( + ChatMessage userInput, + CancellationToken cancellationToken) + { + throw new NotSupportedException("the voice layer only ever starts a turn."); + } + + public Task StartTurnAsync( + ChatMessage userInput, ConversationTurnOrigin? origin, CancellationToken cancellationToken = default) + { + ScriptedVoiceTurn turn; + int turnIndex; + lock (_gate) + { + if (_running is not null) + { + throw new InvalidOperationException("another turn of this conversation is still running."); + } + + turnIndex = _started; + turn = _running = TurnLocked(++_started); + } + + IAsyncEnumerable updates = turn.StreamAsync( + userInput.Text ?? string.Empty, + () => { lock (_gate) { _running = null; } }, + cancellationToken); + return Task.FromResult(new TurnRun(turnIndex, updates)); + } + + public bool Cut(int turnIndex, TurnCut cut) + { + ScriptedVoiceTurn? running; + lock (_gate) + { + if (turnIndex < 0 || turnIndex >= _started) + { + return false; + } + + _cuts.Add((turnIndex, cut)); + running = turnIndex == _started - 1 ? _running : null; + } + + if (EndTurnOnCut) + { + running?.End(); + } + + return true; + } + + public bool Recut(int turnIndex, TurnCut cut) + { + lock (_gate) + { + if (turnIndex != _started - 1 || !_cuts.Exists(recorded => recorded.TurnIndex == turnIndex)) + { + return false; + } + + _recuts.Add((turnIndex, cut)); + return true; + } + } + + private ScriptedVoiceTurn TurnLocked(int ordinal) + { + while (_turns.Count < ordinal) + { + _turns.Add(new ScriptedVoiceTurn()); + } + + return _turns[ordinal - 1]; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs new file mode 100644 index 00000000..a522bf18 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ScriptedVoiceTurn.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// One engine turn a test streams by hand, one update at a time. + internal sealed class ScriptedVoiceTurn + { + private readonly Channel _updates = Channel.CreateUnbounded(); + private readonly Channel _taken = Channel.CreateUnbounded(); + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets a task that completes with the caller's words once the engine turn has started. + public Task Started => _started.Task; + + /// Gets a task that completes once the engine turn has ended, however it ended. + public Task Finished => _finished.Task; + + /// Streams text, then waits until the reader has handled it. + public Task TextAsync(string text) => PushAsync(new TextContent(text)); + + /// Streams a tool call, then waits until the reader has handled it. + public Task CallAsync(string callId, string name = "lookup") => PushAsync(new FunctionCallContent(callId, name)); + + /// Streams a tool result, then waits until the reader has handled it. + public Task ResultAsync(string callId) => PushAsync(new FunctionResultContent(callId, "done")); + + /// Ends the turn once every update pushed so far is read. + public void End() => _updates.Writer.TryComplete(); + + /// Plays the turn to the engine's reader. + internal async IAsyncEnumerable StreamAsync( + string userInput, + Action onEnd, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + try + { + _ = _started.TrySetResult(userInput); + while (await _updates.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + while (_updates.Reader.TryRead(out ChatResponseUpdate? update)) + { + yield return update; + _ = _taken.Writer.TryWrite(true); + } + } + } + finally + { + onEnd(); + _ = _finished.TrySetResult(); + } + } + + private async Task PushAsync(AIContent content) + { + await _updates.Writer.WriteAsync(new ChatResponseUpdate(ChatRole.Assistant, [content])).ConfigureAwait(false); + _ = await Task.WhenAny(_taken.Reader.ReadAsync().AsTask(), _finished.Task).ConfigureAwait(false); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/ShellPidChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/ShellPidChatClient.cs new file mode 100644 index 00000000..74e00531 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/ShellPidChatClient.cs @@ -0,0 +1,96 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// A deterministic offline model: on a message with no tool result yet, calls whatever the first + /// offered tool is with ; once it sees the tool's result, records it and answers + /// "done". Works unmodified across separate turns because each turn's last message before the + /// model runs is the fresh user input, not an earlier turn's tool result — so the same instance + /// drives as many turns as a test needs, one shell pid recorded per turn. + /// + internal sealed class ShellPidChatClient : IChatClient + { + public List ToolResults { get; } = []; + + /// Gets the user words of every request the model was sent, one list per request, oldest first. + public List> UserWords { get; } = []; + + /// Gets or sets the shell command the next tool call runs. Its first line of output must be the pid. + public string Command { get; set; } = "echo $$"; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + await Task.Yield(); + + List transcript = [.. messages]; + UserWords.Add([.. transcript.Where(message => message.Role == ChatRole.User && message.Text.Length > 0).Select(message => message.Text)]); + string responseId = Guid.NewGuid().ToString("N"); + + FunctionResultContent? result = transcript.Count > 0 + ? transcript[^1].Contents.OfType().FirstOrDefault() + : null; + + if (result is not null) + { + ToolResults.Add(result.Result?.ToString() ?? string.Empty); + yield return new ChatResponseUpdate(ChatRole.Assistant, "done") + { + ResponseId = responseId, + MessageId = responseId, + }; + yield break; + } + + AIFunction tool = options?.Tools?.OfType().FirstOrDefault() + ?? throw new InvalidOperationException("The request offered no shell tool for the client to call."); + + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent( + "call_" + Guid.NewGuid().ToString("N"), + tool.Name, + new Dictionary(StringComparer.Ordinal) { ["command"] = Command })]) + { + ResponseId = responseId, + MessageId = responseId, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + + /// Reads the pid off the first line of a run_shell tool result, as MAF formats it. + internal static int ParsePid(string toolResult) + { + return int.Parse(toolResult.Split('\n', StringSplitOptions.TrimEntries)[0]); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs new file mode 100644 index 00000000..00e925d2 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/StallOnCueChatClient.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// A model that answers "reply to" the caller's last words, except words that start with : those + /// get one piece, then a stall that only the run's cancellation (a client abort) ends. + /// + internal sealed class StallOnCueChatClient : IChatClient + { + /// The words that make the reply stall. + public const string Cue = "stall"; + + /// The piece a stalled reply sends before it stalls. + public const string Piece = "partial"; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string last = messages.LastOrDefault(message => message.Role == ChatRole.User)?.Text ?? string.Empty; + if (!last.StartsWith(Cue, StringComparison.Ordinal)) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "reply to " + last); + yield break; + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, Piece); + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/StallingChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/StallingChatClient.cs new file mode 100644 index 00000000..ce17e646 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/StallingChatClient.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Yields one piece, then hangs until its run is cancelled: a host abort's only signal. + internal sealed class StallingChatClient : IChatClient + { + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Waits until the first piece of the reply has left the client. + public Task WaitUntilStreamingAsync() + { + return _started.Task; + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "partial"); + _ = _started.TrySetResult(); + + // Never completes on its own: only the run's own cancellation (a host abort) ends it. + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate? update in GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/StoredRows.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/StoredRows.cs new file mode 100644 index 00000000..5e299a0a --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/StoredRows.cs @@ -0,0 +1,61 @@ +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Reads the rows a conversation wrote once its writes have settled. + internal static class StoredRows + { + /// Waits for at least rows, then a little longer for a stray second append. + public static async Task> SettleAsync(IServiceProvider services, string conversationId, int atLeast) + { + IConversationStore store = services.GetRequiredService(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + await Poll.UntilAsync(() => Read(store, conversationId).Count >= atLeast); + + await Task.Delay(300, cancellationToken); + return Read(store, conversationId); + } + + /// Names each row by role and contents, so a failed order check prints the whole turn. + public static string Describe(IEnumerable rows) + { + return string.Join(" | ", rows.Select(row => row.Content.Role + "[" + string.Join(",", row.Content.Contents.Select(content => content switch + { + TextContent text => "text:" + text.Text, + FunctionCallContent call => "call:" + call.CallId, + FunctionResultContent result => "result:" + result.CallId, + _ => content.GetType().Name, + })) + "]")); + } + + /// Checks every row in order: its role, its words, and whether it carries a tool call or a tool result. + public static void AssertTurn(List rows, params (string Role, string Text, bool Call, bool Result)[] expected) + { + string all = Describe(rows); + Assert.True(expected.Length == rows.Count, all); + for (int index = 0; index < expected.Length; index++) + { + ChatMessage message = rows[index].Content; + Assert.True( + expected[index] == (message.Role.Value, message.Text, message.Contents.OfType().Any(), message.Contents.OfType().Any()), + $"row {index}: {all}"); + } + } + + private static List Read(IConversationStore store, string conversationId) + { + try + { + return [.. store.ReadForSessionAsync(conversationId, TestContext.Current.CancellationToken).AsTask().GetAwaiter().GetResult()]; + } + catch (InvalidOperationException) + { + return []; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs index 34655d3b..27814a05 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TelnyxRelayHost.cs @@ -2,7 +2,7 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; -using AgentCore.AspNetCore.Conversation; +using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Vendors.TelnyxRelay; @@ -47,7 +47,10 @@ private TelnyxRelayHost(WebApplication app, Uri socketAddress, HttpClient client } /// Gets the sessions the host resolved, so a test reads the live ones. - public IConversationSessions Sessions => _app.Services.GetRequiredService().ForSessions("main"); + public IConversationSessions Sessions => _app.Services.GetRequiredService().Sessions; + + /// Gets the host's services, so a test reads the store the conversation wrote. + public IServiceProvider Services => _app.Services; /// Gets the ws:// address of the relay route. /// @@ -227,7 +230,7 @@ public Task GetAsync(string pattern) /// The session, or null. public async Task FindSessionAsync(string conversationId) { - return await Sessions.TryGetAsync(conversationId, TestContext.Current.CancellationToken); + return await Sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, conversationId, TestContext.Current.CancellationToken); } /// Waits until the store no longer holds one conversation. diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TestHostLifetime.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TestHostLifetime.cs new file mode 100644 index 00000000..5beff668 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TestHostLifetime.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// An application lifetime a test owns, so a test can stop the host without one. + /// + internal sealed class TestHostLifetime : IHostApplicationLifetime, IDisposable + { + private readonly CancellationTokenSource _started = new(); + private readonly CancellationTokenSource _stopping = new(); + private readonly CancellationTokenSource _stopped = new(); + + public CancellationToken ApplicationStarted => _started.Token; + + public CancellationToken ApplicationStopping => _stopping.Token; + + public CancellationToken ApplicationStopped => _stopped.Token; + + public void StopApplication() + { + _stopping.Cancel(); + _stopped.Cancel(); + } + + public void Dispose() + { + _started.Dispose(); + _stopping.Dispose(); + _stopped.Dispose(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TurnHoldingStore.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TurnHoldingStore.cs new file mode 100644 index 00000000..5c105b96 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TurnHoldingStore.cs @@ -0,0 +1,72 @@ +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Conversation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using AgentCore.TestSupport; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// An in-memory store that holds the first append naming one turn until the test releases it, and reports the first + /// time a turn found the conversation marked busy by another request. It is also its own adapter, of kind + /// test. + /// + /// The turn whose first append is held. + internal sealed class TurnHoldingStore(int heldTurn) : DelegatingConversationStore(new InMemoryConversationStore()), IConversationStoreAdapter + { + private readonly TaskCompletionSource _held = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly TaskCompletionSource _markRefused = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private int _holding; + + public string Kind => "test"; + + /// Gets a task that completes once the held append reached the store. + public Task Held => _held.Task; + + /// Gets a task that completes the first time a mark was refused. + public Task MarkRefused => _markRefused.Task; + + public void Release() + { + _ = _release.TrySetResult(); + } + + public ValueTask OpenAsync( + VendorProviderConfiguration entry, ISecretResolverPort? secrets, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(this); + } + + public override async ValueTask> AppendAsync( + string conversationId, + IReadOnlyList messages, + ConversationSessionState? state = null, + CancellationToken cancellationToken = default) + { + if (messages.Any(message => message.TurnIndex == heldTurn) && Interlocked.Exchange(ref _holding, 1) == 0) + { + _ = _held.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + } + + return await base.AppendAsync(conversationId, messages, state, cancellationToken); + } + + public override async ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + bool marked = await base.TryMarkBusyAsync(conversationId, holder, lease, cancellationToken); + if (!marked) + { + _ = _markRefused.TrySetResult(); + } + + return marked; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/TwoStepChatClient.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/TwoStepChatClient.cs new file mode 100644 index 00000000..1dd8f4e4 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/TwoStepChatClient.cs @@ -0,0 +1,132 @@ +using System.Runtime.CompilerServices; +using AgentCore.AspNetCore.DependencyInjection; +using Microsoft.Extensions.AI; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// + /// A model whose reply runs in two steps: step one speaks and calls the document's one tool, step two speaks + /// after the tool result. Either step can stop at until the request is cancelled. Every + /// request after a tool result runs step two again, so a step two that calls a failing tool calls it until + /// the tool budget ends the run. + /// + /// The fragments of step one; marks where the tool call goes. + /// The fragments of step two. + internal sealed class TwoStepChatClient(object[] stepOne, object[] stepTwo) : IChatClient + { + /// The binding name the tool of binds to. + public const string ToolBinding = "LookItUp"; + + /// A document with one agent and one tool, bound to . + public const string Yaml = + """ + apiVersion: agentcore/v1 + tools: + - id: look_it_up + kind: binding + binds: LookItUp + description: Look something up for the caller. + parameters: + type: object + properties: { what: { type: string } } + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller", tools: [ look_it_up ] } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: greeter + """; + + /// Marks where a step stops until its request is cancelled. + public static readonly object Pause = new(); + + /// Marks where step one calls the tool. + public static readonly object Call = new(); + + private int _calls; + + /// Gets a task that completes when a step reaches . + public TaskCompletionSource Paused { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Binds the tool of to one that answers at once. + public static void BindTool(AgentCoreOptions options) + { + _ = options.Bind(ToolBinding, (_, _) => ValueTask.FromResult("42")); + } + + /// + /// Binds the tool of to one that answers its first call and then fails as an unreachable + /// endpoint does, which counts toward the budget that ends the run on the fourth failure in a row. + /// + public static void BindToolThatFailsAfterItsFirstCall(AgentCoreOptions options) + { + int calls = 0; + _ = options.Bind(ToolBinding, (_, _) => Interlocked.Increment(ref calls) == 1 + ? ValueTask.FromResult("42") + : throw new HttpRequestException("the order service is down")); + } + + /// Gets with its own fallback line. + public static string YamlWithFallback(string fallbackReply) + { + return Yaml.Replace("apiVersion: agentcore/v1\n", $"apiVersion: agentcore/v1\nfallbackReply: \"{fallbackReply}\"\n", StringComparison.Ordinal); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + bool answered = messages.LastOrDefault()?.Role == ChatRole.Tool; + string id = Guid.NewGuid().ToString("N"); + + foreach (object item in answered ? stepTwo : stepOne) + { + await Task.Yield(); + if (ReferenceEquals(item, Pause)) + { + _ = Paused.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken); + continue; + } + + AIContent content = ReferenceEquals(item, Call) + ? new FunctionCallContent("call_" + Interlocked.Increment(ref _calls), options!.Tools!.OfType().First().Name, new Dictionary()) + : new TextContent((string)item); + yield return new ChatResponseUpdate(ChatRole.Assistant, [content]) { ResponseId = id, MessageId = id }; + } + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs new file mode 100644 index 00000000..6986fdb2 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/UserStateLog.cs @@ -0,0 +1,46 @@ +using AgentCore.AspNetCore.Voice; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// Every user state change a raises, in order. + internal sealed class UserStateLog + { + private readonly Lock _gate = new(); + private readonly List _changes = []; + private readonly List<(int Count, TaskCompletionSource Reached)> _waiters = []; + + public UserStateLog(VoiceSession session) + { + session.UserStateChanged += change => + { + lock (_gate) + { + _changes.Add(change); + _ = _waiters.RemoveAll(waiter => waiter.Count <= _changes.Count && waiter.Reached.TrySetResult()); + } + }; + } + + /// Gets each change's new state, in order. + public IReadOnlyList NewStates + { + get { lock (_gate) { return [.. _changes.Select(change => change.NewState)]; } } + } + + /// Waits until at least changes were raised. + public Task WaitForCountAsync(int count) + { + lock (_gate) + { + if (_changes.Count >= count) + { + return Task.CompletedTask; + } + + TaskCompletionSource reached = new(TaskCreationOptions.RunContinuationsAsynchronously); + _waiters.Add((count, reached)); + return reached.Task; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs new file mode 100644 index 00000000..6253c527 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/VoiceLoopHarness.cs @@ -0,0 +1,81 @@ +using System.Threading.Channels; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AgentCore.AspNetCore.Tests.Fakes +{ + /// One over real conversation sessions and a fake model, fed by hand. + internal sealed class VoiceLoopHarness + { + private readonly Channel _inputs = Channel.CreateUnbounded(); + + private VoiceLoopHarness(IChatClient reply, IConversationOutputPort output) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(TelnyxRelayTurnTests.PolicyYaml); + RoutingChatClientFactory chatClients = new(reply); + CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(chatClients))[SingleEntrySessionFactories.MainEntry]; + ConversationSessionFactory factory = new(compiled, new GuardEvaluator(compiled.Configuration.Guards), extractor: null); + + Sessions = new InMemoryConversationSessions( + SingleEntrySessionFactories.Of(factory), + TimeSpan.FromMinutes(5), + TimeProvider.System); + Loop = new VoiceConversationLoop( + Sessions, + SingleEntrySessionFactories.MainEntry, + output, + new ConnectionTaskObserver(() => Loop?.ConversationId ?? "(none)", (_, _) => { }, (_, _, _) => { }, (_, _, _) => false), + TimeProvider.System, + NullLogger.Instance, + TimeSpan.FromSeconds(5), + CancellationToken.None, + new VoiceOptions(UserAway: null, new Dictionary())); + } + + /// Gets the sessions the loop opens conversations in. + public InMemoryConversationSessions Sessions { get; } + + /// Gets the loop under test. + public VoiceConversationLoop Loop { get; } + + /// Builds the loop, reading nothing yet. + /// The model behind every agent. + /// Where every reply goes. + /// The harness. + public static VoiceLoopHarness Create(IChatClient reply, IConversationOutputPort output) + { + return new(reply, output); + } + + /// Starts the loop reading from inputs the test sends by hand. + /// The model behind every agent. + /// Where every reply goes. + /// The harness, and the loop's own reading task. + public static (VoiceLoopHarness Harness, Task Running) Start(IChatClient reply, IConversationOutputPort output) + { + VoiceLoopHarness harness = new(reply, output); + return (harness, harness.Loop.RunAsync(harness._inputs.Reader.ReadAllAsync())); + } + + /// Hands the loop one inbound event. + public void Send(ConversationInput input) + { + _ = _inputs.Writer.TryWrite(input); + } + + /// Ends the inbound stream, so the loop's reading task ends. + public void Complete() + { + _ = _inputs.Writer.TryComplete(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs b/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs new file mode 100644 index 00000000..818bdcb5 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Sessions/AgentCoreAgentSessionStoreTests.cs @@ -0,0 +1,91 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Sessions; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Sessions +{ + /// + /// MAF's own path — GetSessionAsync/SaveSessionAsync called + /// directly, the way an AG-UI or A2A host would, without ever going through the Responses endpoint. + /// + public sealed class AgentCoreAgentSessionStoreTests + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: greeter + """; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task AnUnknownKey_KeepsResolvingToTheSameConversation_PastTheRetentionWindow() + { + FakeTimeProvider clock = new(DateTimeOffset.UtcNow); + InMemoryConversationStore store = new(clock); + AgentCoreAgentSessionStore sessions = new(store); + AgentCoreAgent agent = BuildAgent(new FragmentingChatClient("hello there"), store); + + // MAF's own path: a key nothing here minted, opened and saved back with no help from + // ResponsesEndpoints. + AgentSession first = await sessions.GetSessionAsync(agent, "external-thread-1", Ct); + _ = await agent.RunAsync("hi", first, cancellationToken: Ct); + await sessions.SaveSessionAsync(agent, "external-thread-1", first, Ct); + + // D3: the key already names its own conversation row, so SaveSessionAsync wrote no response + // row for it — nothing here for a sweep to find. + clock.Advance(TimeSpan.FromDays(31)); + int swept = await store.SweepAsync(TimeSpan.FromDays(30), cancellationToken: Ct); + Assert.Equal(0, swept); + + AgentSession resumed = await sessions.GetSessionAsync(agent, "external-thread-1", Ct); + ConversationSession conversation = resumed.GetService()!; + IReadOnlyList rows = await store.ReadForSessionAsync(conversation.ConversationId, Ct); + + Assert.Equal("external-thread-1", conversation.ConversationId); + Assert.Contains(rows, row => row.Content.Role == ChatRole.User && row.Content.Text == "hi"); + } + + private static AgentCoreAgent BuildAgent(IChatClient reply, IConversationStore store) + { + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(Yaml), + new AgentCompilationContext(new RoutingChatClientFactory(reply)) { ConversationStore = store })["main"]; + + InMemoryConversationSessions sessions = new( + new Dictionary(StringComparer.Ordinal) + { + ["main"] = new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)), + }, + TimeSpan.FromMinutes(30), + TimeProvider.System); + + return new AgentCoreAgent(sessions, "main"); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs b/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs index 9f4f26dd..3f774b4f 100644 --- a/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Sessions/ConversationSessionsRelayTests.cs @@ -1,14 +1,22 @@ +using System.Net.WebSockets; +using AgentCore.Application.Audit; +using AgentCore.Application.Audit.Memory; using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.DependencyInjection.Startup; using AgentCore.AspNetCore.Tests.Fakes; using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; +using AgentCore.Domain.Audit; using AgentCore.TestSupport; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Xunit; namespace AgentCore.AspNetCore.Tests.Sessions @@ -22,14 +30,148 @@ namespace AgentCore.AspNetCore.Tests.Sessions /// public sealed class ConversationSessionsRelayTests { + private const string TwoEntryPolicyYaml = + """ + apiVersion: agentcore/v1 + agents: + defaults: + model: { ref: reply } + items: + - { id: greeter, instructions: "greet the caller" } + providers: + conversation: { kind: telnyx-relay } + speech: + stt: { kind: telnyx-relay } + tts: { kind: telnyx-relay } + llm: + - { kind: openai, model: gpt-4.1-mini, as: reply } + entries: + main: + agent: greeter + other: + agent: greeter + """; + private static CancellationToken Token => TestContext.Current.CancellationToken; + // If TelnyxRelayConnection ever hard-coded "main" in place of the route's own entry, this would + // open under "main" instead and the assertion below would fail. + [Fact(Timeout = 30_000)] + public async Task ARequestOnAnEntryOtherThanMainOpensItsSessionUnderThatEntry() + { + using FragmentingChatClient reply = new("hello"); + List openedUnder = []; + + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TwoEntryPolicyYaml, + reply, + configure: options => options.UseConversationSessions(factories => new EntryRecordingConversationSessions( + new InMemoryConversationSessions(factories, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System), + openedUnder)), + entry: "other"); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-other-entry")); + harness.Socket.Queue(RelayFrames.Prompt("hi", last: true)); + harness.Socket.QueueClose(); + await harness.Connection.WaitAsync(Token); + + Assert.Contains("other", openedUnder); + Assert.DoesNotContain("main", openedUnder); + } + + [Fact(Timeout = 30_000)] + public async Task ASetupNamingAnIdAnotherEntryHoldsClosesAsAPolicyViolationAndLeavesThatSessionUntouched() + { + using FragmentingChatClient reply = new("hello"); + EventObservedLoggerProvider inUse = new("ConversationInUse"); + EventObservedLoggerProvider faulted = new("ReadLoopFaulted"); + + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TwoEntryPolicyYaml, + reply, + logging: builder => builder.AddProvider(inUse).AddProvider(faulted), + entry: "other"); + + IConversationSessions sessions = harness.Services.GetRequiredService().Sessions; + ConversationSession held = await sessions.GetOrOpenAsync(SingleEntrySessionFactories.MainEntry, "conversation-held", null, Token); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-held")); + await harness.Connection.WaitAsync(Token); + + Assert.Equal(WebSocketCloseStatus.PolicyViolation, harness.Socket.CloseSent?.Status); + Assert.Equal(1, inUse.Count); + Assert.Equal(LogLevel.Warning, inUse.Level); + Assert.Contains("conversation-held", inUse.Message, StringComparison.Ordinal); + Assert.Equal(0, faulted.Count); + + QueuedAuditSink queue = Assert.IsType(harness.Services.GetRequiredService()); + await queue.FlushAsync(Token); + IReadOnlyList chain = Assert.IsType(queue.Store).EventsOf("conversation-held"); + _ = Assert.Single(chain, item => item.Kind == AuditEventKind.ConversationStarted); + Assert.DoesNotContain(chain, item => item.Kind == AuditEventKind.ConversationEnded); + + Assert.Same(held, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-held", Token)); + Assert.False(held.IsComplete); + } + + [Fact(Timeout = 30_000)] + public async Task ARefusedReopenAfterClosingTheReplacedSessionLeavesTheOtherEntrysSessionUntouched() + { + using FragmentingChatClient reply = new("hello"); + string root = Path.Combine(Path.GetTempPath(), "agentcore-session-race-" + Guid.NewGuid().ToString("N")); + + try + { + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TwoEntryPolicyYaml, + reply, + configure: options => + { + _ = options.UseWorkspace(root); + _ = options.UseConversationSessions(factories => new ForcedReopenConversationSessions( + new InMemoryConversationSessions(factories, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System), + closingEntry: "main", + racingEntry: "other")); + }, + entry: "main"); + + // Two setup frames for the same id: the loop closes its own hold, and in that window this + // forces "other" to open the id before the loop's reopen is attempted. + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-race")); + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-race")); + await harness.Connection.WaitAsync(Token); + + Assert.Equal(WebSocketCloseStatus.PolicyViolation, harness.Socket.CloseSent?.Status); + + ForcedReopenConversationSessions sessions = Assert.IsType( + harness.Services.GetRequiredService().Sessions); + ConversationSession racedInto = sessions.RacedInto ?? throw new InvalidOperationException("the race never fired."); + + Assert.Same(racedInto, await sessions.TryGetAsync("other", "conversation-race", Token)); + Assert.False(racedInto.IsComplete); + Assert.True(Directory.Exists(racedInto.Workspace)); + Assert.True(File.Exists(Path.Combine(racedInto.Workspace!, "sentinel.txt"))); + + QueuedAuditSink queue = Assert.IsType(harness.Services.GetRequiredService()); + await queue.FlushAsync(Token); + IReadOnlyList chain = Assert.IsType(queue.Store).EventsOf("conversation-race"); + Assert.DoesNotContain(chain, item => item.Kind == AuditEventKind.ConversationEnded); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + [Fact(Timeout = 30_000)] public async Task ATurnTellsTheStoreItsConversationIsStillBeingHad() { // The relay opens its session once and holds it for the whole conversation, so nothing else reads // it back. A read is the one sign a store gets that a conversation is still live, so without one - // the idle sweep drops a long call out from under the turn about to run. + // the idle timeout drops a long call out from under the turn about to run. using FragmentingChatClient reply = new("your order ships Friday"); ConversationSessionFactory factory = Factory(TelnyxRelayTurnTests.PolicyYaml, reply); CountingConversationSessions sessions = new(factory); @@ -37,7 +179,7 @@ public async Task ATurnTellsTheStoreItsConversationIsStillBeingHad() await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( TelnyxRelayTurnTests.PolicyYaml, reply, - configure: options => options.UseConversationSessions((_, _) => sessions)); + configure: options => options.UseConversationSessions(_ => sessions)); harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-live")); harness.Socket.Queue(RelayFrames.Prompt("when does my order ship?", last: true)); @@ -54,7 +196,7 @@ private static ConversationSessionFactory Factory(string yaml, IChatClient reply { AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(yaml); RoutingChatClientFactory chatClients = new(reply); - CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(chatClients))["main"]; + CompiledAgent compiled = ConfigurationCompiler.CompileAll(document, new AgentCompilationContext(chatClients))[SingleEntrySessionFactories.MainEntry]; return new ConversationSessionFactory( compiled, @@ -62,29 +204,110 @@ private static ConversationSessionFactory Factory(string yaml, IChatClient reply ConversationSessionFactory.CreateExtractor(compiled, chatClients)); } - private sealed class CountingConversationSessions(IConversationSessionFactory factory) : IConversationSessions + private sealed class CountingConversationSessions(IConversationSessionFactory factory) : IConversationSessions, IDisposable { - private readonly InMemoryConversationSessions _inner = - new(factory, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + private readonly InMemoryConversationSessions _inner = new( + SingleEntrySessionFactories.Of(factory), + InMemoryConversationSessions.DefaultIdleTimeout, + TimeProvider.System); + + public void Dispose() + { + _inner.Dispose(); + } private int _reads; public int Reads => Volatile.Read(ref _reads); - public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default) + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) { - return _inner.OpenAsync(conversationId, cancellationToken); + return _inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); } - public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { _ = Interlocked.Increment(ref _reads); - return _inner.TryGetAsync(conversationId, cancellationToken); + return _inner.TryGetAsync(entry, conversationId, cancellationToken); + } + + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return _inner.CloseAsync(entry, conversationId, cancellationToken); + } + } + + /// Sessions that record the entry every open named, so a test can read what the route actually sent. + private sealed class EntryRecordingConversationSessions(InMemoryConversationSessions inner, List openedUnder) + : IConversationSessions, IDisposable + { + public void Dispose() + { + inner.Dispose(); + } + + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) + { + lock (openedUnder) + { + openedUnder.Add(entry); + } + + return inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); + } + + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return inner.TryGetAsync(entry, conversationId, cancellationToken); } - public ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { - return _inner.CloseAsync(conversationId, cancellationToken); + return inner.CloseAsync(entry, conversationId, cancellationToken); + } + } + + /// + /// Forces the race a refused re-open can land in: once 's hold of an id is + /// released, opens that same id under before the caller's own reopen runs, + /// and drops a sentinel file into its workspace so a test can tell whether a later teardown reached it. + /// + private sealed class ForcedReopenConversationSessions(InMemoryConversationSessions inner, string closingEntry, string racingEntry) + : IConversationSessions, IDisposable + { + public ConversationSession? RacedInto { get; private set; } + + public void Dispose() + { + inner.Dispose(); + } + + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) + { + return inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); + } + + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return inner.TryGetAsync(entry, conversationId, cancellationToken); + } + + public async ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + await inner.CloseAsync(entry, conversationId, cancellationToken).ConfigureAwait(false); + + if (RacedInto is not null || !string.Equals(entry, closingEntry, StringComparison.Ordinal)) + { + return; + } + + ConversationSession raced = await inner.GetOrOpenAsync(racingEntry, conversationId, null, cancellationToken).ConfigureAwait(false); + if (raced.Workspace is { } path) + { + File.WriteAllText(Path.Combine(path, "sentinel.txt"), "held by " + racingEntry); + } + + RacedInto = raced; } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs new file mode 100644 index 00000000..2cf95f29 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInGuardTests.cs @@ -0,0 +1,259 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// What the interrupt guard does to frames already in flight: what the caller is recorded as having + /// heard, and what must never reach the relay after the cut. + /// + public sealed class TelnyxRelayBargeInGuardTests + { + [Fact(Timeout = 30_000)] + public async Task AnInterruptDuringAReply_RecordsWhatTheCallerHeard() + { + // The vendor measured both values, so nothing here is estimated. D28 and item 6a. + // + // BlockingChatClient's gate ignores cancellation, so a defect that left the turn + // machinery stuck would hang this test rather than fail it red. The ten-second deadline + // below is this test's own backstop, and reply.Release() sits in a finally so a failed + // assertion above still opens the gate instead of leaving the server's turn blocked for + // the rest of the host's shutdown timeout. + using BlockingChatClient reply = new("one two three four five"); + EventObservedLoggerProvider capture = new("InterruptReceived"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(capture)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + try + { + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-barge")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + + try + { + _ = await relay.ReadFrameAsync().WaitAsync(bounded.Token); // the reply started + await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); + + await relay.SendAsync(RelayFrames.Interrupt("one two", durationMs: 640)); + + // SendAsync completing only means the bytes left this client, the same way + // TelnyxRelayHost.WaitForSessionAsync's own remark explains for a setup frame. The + // connection must actually raise the turn id and conversation ConversationSession.Interrupt before + // the gate below opens, or a fragment already queued behind the gate could still + // beat the guard onto the wire. + await capture.Observed.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported the barge-in within ten seconds."); + } + } + finally + { + reply.Release(); + } + + ConversationSession? session = await host.FindSessionAsync("conversation-barge"); + Assert.NotNull(session); + + TurnResult turn = await TelnyxRelayBargeInTestSupport.WaitForTurnAsync(session!); + + Assert.Equal("one two", turn.ReplyText); + Assert.Equal(TimeSpan.FromMilliseconds(640), turn.Cut); + } + + [Fact(Timeout = 30_000)] + public async Task AfterAnInterrupt_NoFurtherTextFrameReachesTheRelay() + { + // Cancelling stops the producer. It does not stop a consumer that is mid-write, and a + // token that lands after the interrupt makes the vendor speak audio nobody asked for. + // pipecat names this exact straggler in its eval harness. + // + // This proves RunTurnAsync's own guard: interrupting before the gate on the fake model is + // ever released means nothing has reached _outbound yet when HandleInterrupt runs, so every + // later fragment must be stopped by the per-update check inside RunTurnAsync's own loop. + // AFrameAlreadyInsideTheWriteLoop_IsNotFollowedByAnotherAfterAnInterrupt below covers the + // case this test's timing cannot reach: a fragment that already reached the channel before + // the interrupt did, sitting there while the write loop is genuinely busy elsewhere. + using BlockingChatClient reply = new("one two three four five six seven eight"); + EventObservedLoggerProvider capture = new("InterruptReceived"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(capture)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + try + { + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-race")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + + try + { + _ = await relay.ReadFrameAsync().WaitAsync(bounded.Token); + await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); + + await relay.SendAsync(RelayFrames.Interrupt("one", durationMs: 300)); + + // The same reason as the test above: the gate must not open until the connection + // has actually raised the turn id, or this test would be timing the delivery of + // one WebSocket frame rather than the guard this task exists to prove. + await capture.Observed.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported the barge-in within ten seconds."); + } + } + finally + { + // Release every remaining update. None of them may reach the socket. This sits in a + // finally so a failed assertion above still lets the fake client's task finish rather + // than leaving it, and the host's shutdown, blocked on a gate nobody will ever open. + reply.Release(); + } + + List later = await relay.ReadFramesForAsync(TimeSpan.FromMilliseconds(400)); + Assert.DoesNotContain(later, frame => frame["type"]!.GetValue() == "text"); + } + + [Fact(Timeout = 30_000)] + public async Task AnItemAlreadyQueuedBehindAStuckSend_IsRemovedByTheUnconditionalDrain() + { + // This does not exercise either id-based guard — not RunTurnAsync's own per-update check, + // and not the write loop's >= comparison in front of its one SendAsync call. Both stay + // disabled and this still passes, because HandleInterrupt's own drain — the unconditional + // `while (_outbound.Reader.TryRead(out _)) { }` that runs before either guard is ever + // reached — already removes the item this test cares about while it is still sitting in + // the channel, before either comparison gets a chance to matter. What this proves is that + // the drain reaches an item already queued, not one still in flight through a guard: a + // single, deliberately huge fragment is the whole reply, so nothing else competes for it. + // The write loop dequeues it almost immediately and calls SendAsync, and that call has no + // choice but to block on genuine TCP backpressure, because nothing reads this socket until + // this test says so. While it is stuck there, the turn's own closing token queues up right + // behind it — still sitting in _outbound, not yet dequeued, when the interrupt fires. The + // drain reaches it there and removes it. Only once this test finally reads does the huge + // fragment complete and arrive, already committed to the wire before the interrupt ever + // happened. Nothing may follow it: not the closing token the drain already removed, and + // not anything else, even though the write loop spent this whole test genuinely busy + // rather than idle when the interrupt landed. + // + // FakeRelayClient cannot prove this: its own pump reads the socket continuously in the + // background from the moment it connects, for every other test's benefit, which means + // nothing is ever actually stuck waiting on a reader through it — this test's first attempt + // used it, held a fixed delay, and failed intermittently exactly because of that pump + // draining the "stuck" frame in the background the whole time. A raw ClientWebSocket here + // is what makes the block real: the OS send buffer this connection can autotune to (4 MiB, + // confirmed via /proc/sys/net/ipv4/tcp_wmem on the machine this was verified on) cannot + // absorb an 8 MiB message with no reader draining it, so SendAsync has no choice but to + // wait. There is still no purely observable signal for "the write loop is now stuck inside + // that wait," so the fixed delay below stands in for one, sized to outlast a same-process + // production of one string and two channel writes many times over. + string hugeFragment = new('x', 8 * 1024 * 1024); + using FragmentingChatClient reply = new(hugeFragment); + EventObservedLoggerProvider capture = new("InterruptReceived"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(capture)); + + using ClientWebSocket socket = new(); + await socket.ConnectAsync(host.Address, TestContext.Current.CancellationToken); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + await SendRawAsync(socket, RelayFrames.Setup(conversationSessionId: "conversation-held")); + await SendRawAsync(socket, RelayFrames.Prompt("hi", last: true)); + + await Task.Delay(300, TestContext.Current.CancellationToken); + + try + { + await SendRawAsync(socket, RelayFrames.Interrupt("nothing played", durationMs: 0)); + await capture.Observed.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported the barge-in within ten seconds."); + } + + try + { + string stuck = await ReceiveOneMessageAsync(socket, bounded.Token); + JsonNode node = JsonNode.Parse(stuck)!; + Assert.Equal("text", node["type"]!.GetValue()); + Assert.False(node["last"]!.GetValue()); + Assert.Equal(hugeFragment, node["token"]!.GetValue()); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail( + "the fragment already committed to the wire before the interrupt never arrived " + + "within ten seconds — it may not have been stuck in the write loop at all."); + } + + // Cancelling this receive is the proof that nothing else arrives in the window: it aborts + // the socket, which is fine, because this is the last thing this test does with it. + using CancellationTokenSource window = new(TimeSpan.FromMilliseconds(400)); + try + { + string extra = await ReceiveOneMessageAsync(socket, window.Token); + Assert.Fail($"an unexpected frame reached the relay after the interrupt: {extra}"); + } + catch (OperationCanceledException) when (window.IsCancellationRequested) + { + // Silence is the pass: nothing followed the frame already in flight. + } + } + + /// Sends one frame as one WebSocket message, bypassing 's own pump. + private static Task SendRawAsync(ClientWebSocket socket, string json) + { + return socket.SendAsync( + Encoding.UTF8.GetBytes(json), + WebSocketMessageType.Text, + endOfMessage: true, + TestContext.Current.CancellationToken); + } + + /// Reads exactly one message off a raw socket, reassembling it from as many fragments as it takes. + private static async Task ReceiveOneMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken) + { + byte[] buffer = new byte[64 * 1024]; + using MemoryStream message = new(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + throw new InvalidOperationException("the host closed the socket before sending a frame."); + } + + message.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + return Encoding.UTF8.GetString(message.ToArray()); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs new file mode 100644 index 00000000..420aefeb --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInRecoveryTests.cs @@ -0,0 +1,229 @@ +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// What happens to the conversation after a barge-in: the interrupted turn is settled and the next + /// turn answers normally. + /// + public sealed class TelnyxRelayBargeInRecoveryTests + { + // Plan B1 (agent_activity.py:2774-2783): a second final prompt interrupts turn one, which keeps the text + // that reached the relay (design section 3), and turn two answers. Nothing is held. + [Fact(Timeout = 30_000)] + public async Task ASecondFinalPromptDuringATurn_InterruptsTheFirstAndTheSecondAnswers() + { + // BlockingChatClient's gate ignores cancellation, so a defect here would hang rather than fail red, + // hence the deadline below. + using BlockingChatClient reply = new("first reply"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + List first; + List second; + try + { + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-second-prompt")); + await relay.SendAsync(RelayFrames.Prompt("one", last: true)); + + try + { + await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); + await relay.SendAsync(RelayFrames.Prompt("two", last: true)); + + // Turn one closes its reply as it is interrupted, with only what reached the relay. + first = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the second prompt never interrupted the first reply within ten seconds."); + throw; + } + } + finally + { + reply.Release(); + } + + try + { + second = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the second prompt never got its answer within ten seconds."); + throw; + } + + ConversationSession? session = await host.FindSessionAsync("conversation-second-prompt"); + Assert.NotNull(session); + await Poll.UntilAsync(() => session!.LastTurn is { TurnIndex: 1 }); + await session!.FlushTranscriptAsync(); + + Assert.Equal("first reply", string.Concat(second)); + Assert.Equal(2, reply.Calls); + Assert.Equal( + ["one", string.Concat(first), "two", "first reply"], + session.Transcript.Select(message => message.Text).Where(text => text.Length > 0)); + } + + [Fact(Timeout = 30_000)] + public async Task ATurnAfterABargeIn_AnswersNormally() + { + // Item 6a is half proved by AfterAnInterrupt_NoFurtherTextFrameReachesTheRelay: a barge-in + // ends one turn cleanly. The other half — the conversation itself goes on — needs its own proof, + // because nothing else here shows the caller can speak again and get a real answer rather + // than silence or a leftover interrupted reply. + using BlockingChatClient reply = new("hello there caller"); + EventObservedLoggerProvider capture = new("InterruptReceived"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(capture)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + try + { + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-continues")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + + try + { + _ = await relay.ReadFrameAsync().WaitAsync(bounded.Token); + await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); + + await relay.SendAsync(RelayFrames.Interrupt("hello", durationMs: 200)); + await capture.Observed.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported the barge-in within ten seconds."); + } + } + finally + { + reply.Release(); + } + + ConversationSession? session = await host.FindSessionAsync("conversation-continues"); + Assert.NotNull(session); + + TurnResult firstTurn = await TelnyxRelayBargeInTestSupport.WaitForTurnAsync(session!); + Assert.Equal(TimeSpan.FromMilliseconds(200), firstTurn.Cut); + + await relay.SendAsync(RelayFrames.Prompt("go on", last: true)); + + try + { + List tokens = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); + Assert.Equal("hello there caller", string.Concat(tokens)); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the turn after the barge-in never finished within ten seconds."); + } + + Assert.Null(session!.LastTurn!.Cut); + } + + // Owner ruling 2026-09-24: once a later turn has started, an earlier turn's reply never changes. Turn one's + // reply is all on the wire and the relay is still playing it; turn two has streamed only content with no + // words. A barge-in then reports what the caller heard of turn one, too late: the engine refuses the cut, + // the refusal is logged, turn one keeps its reply, and turn two speaks on. + [Fact(Timeout = 30_000)] + public async Task AnInterruptAfterTheNextTurnStarted_LeavesTheTurnTheCallerHeardAsItWas() + { + using HeldPromptChatClient reply = new("first reply", "second reply") + { + SecondTurnOpensWithUnspokenContent = true, + }; + EventObservedLoggerProvider interrupted = new("InterruptReceived"); + EventObservedLoggerProvider refused = new("CutRefused"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(interrupted).AddProvider(refused)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-thinking-turn")); + await relay.SendAsync(RelayFrames.Prompt("one", last: true)); + reply.ReleaseFirstTurn(); + + ConversationSession? session = null; + try + { + Assert.Equal("first reply", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); + session = await host.FindSessionAsync("conversation-thinking-turn"); + Assert.NotNull(session); + await Poll.UntilAsync(() => session.LastTurn is not null); + + await relay.SendAsync(RelayFrames.Prompt("two", last: true)); + + // Turn two has thought aloud once. The caller has heard nothing of it: reasoning carries no text frame. + await reply.SecondTurnStarted.Task.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the second turn never reached its first thought within ten seconds."); + } + + TurnResult? first = session!.LastTurn; + Assert.NotNull(first); + Assert.Null(first!.Cut); + + try + { + await relay.SendAsync(RelayFrames.Interrupt("first", durationMs: 640)); + await interrupted.Observed.WaitAsync(bounded.Token); + await refused.Observed.WaitAsync(bounded.Token); + + // Read before the gate opens, while turn one is still the turn that finished last. + Assert.Equal(LogLevel.Information, refused.Level); + Assert.Equal(first.TurnIndex, session.LastTurn!.TurnIndex); + Assert.Equal("first reply", session.LastTurn.ReplyText); + Assert.Null(session.LastTurn.Cut); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported the barge-in within ten seconds."); + } + finally + { + reply.ReleaseSecondTurn(); + } + + try + { + List second = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); + Assert.Equal("second reply", string.Concat(second)); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail( + "the second turn's answer never reached the relay within ten seconds; the barge-in " + + "cut a turn the caller had only heard silence from."); + } + + // Turn two ran to its own end. Nothing wrote the heard text onto it. + await Poll.UntilAsync(() => session.LastTurn!.TurnIndex == first.TurnIndex + 1); + Assert.Equal("second reply", session.LastTurn!.ReplyText); + Assert.Null(session.LastTurn.Cut); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs new file mode 100644 index 00000000..2df38fc1 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTestSupport.cs @@ -0,0 +1,26 @@ +using AgentCore.Application.Runtime; +using AgentCore.Domain; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// What the barge-in test classes share: polling for the turn an interrupt settles. + internal static class TelnyxRelayBargeInTestSupport + { + /// Polls for the turn a barge-in ends, bounded the same way TelnyxRelayHost's own waits are. + internal static async Task WaitForTurnAsync(ConversationSession session) + { + for (int attempt = 0; attempt < 1_000; attempt++) + { + if (session.LastTurn is { } turn) + { + return turn; + } + + await Task.Delay(10, TestContext.Current.CancellationToken); + } + + throw new TimeoutException("the interrupted turn never finished within ten seconds."); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTests.cs deleted file mode 100644 index e9712972..00000000 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayBargeInTests.cs +++ /dev/null @@ -1,797 +0,0 @@ -using System.Net.WebSockets; -using System.Text; -using System.Text.Json.Nodes; -using AgentCore.Application.Runtime; -using AgentCore.AspNetCore.Tests.Fakes; -using AgentCore.Domain; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay -{ - /// - /// What happens when the caller speaks over the reply. - /// - /// - /// Every test here runs offline against a fake model and a fake relay. There is no Telnyx account, - /// no network conversation, and no API key anywhere in this file. - /// - public sealed class TelnyxRelayBargeInTests - { - [Fact(Timeout = 30_000)] - public async Task AnInterruptDuringAReply_RecordsWhatTheCallerHeard() - { - // The vendor measured both values, so nothing here is estimated. D28 and item 6a. - // - // BlockingChatClient's gate ignores cancellation, so a defect that left the turn - // machinery stuck would hang this test rather than fail it red. The ten-second deadline - // below is this test's own backstop, and reply.Release() sits in a finally so a failed - // assertion above still opens the gate instead of leaving the server's turn blocked for - // the rest of the host's shutdown timeout. - using BlockingChatClient reply = new("one two three four five"); - EventObservedLoggerProvider capture = new("InterruptReceived"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-barge")); - await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); - - try - { - _ = await relay.ReadFrameAsync().WaitAsync(bounded.Token); // the reply started - await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Interrupt("one two", durationMs: 640)); - - // SendAsync completing only means the bytes left this client, the same way - // TelnyxRelayHost.WaitForSessionAsync's own remark explains for a setup frame. The - // connection must actually raise the turn id and conversation ConversationSession.Interrupt before - // the gate below opens, or a fragment already queued behind the gate could still - // beat the guard onto the wire. - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported the barge-in within ten seconds."); - } - } - finally - { - reply.Release(); - } - - ConversationSession? session = await host.FindSessionAsync("conversation-barge"); - Assert.NotNull(session); - - TurnResult turn = await WaitForTurnAsync(session!); - - Assert.Equal("one two", turn.ReplyText); - Assert.Equal(TimeSpan.FromMilliseconds(640), turn.InterruptedAfter); - } - - [Fact(Timeout = 30_000)] - public async Task AfterAnInterrupt_NoFurtherTextFrameReachesTheRelay() - { - // Cancelling stops the producer. It does not stop a consumer that is mid-write, and a - // token that lands after the interrupt makes the vendor speak audio nobody asked for. - // pipecat names this exact straggler in its eval harness. - // - // This proves RunTurnAsync's own guard: interrupting before the gate on the fake model is - // ever released means nothing has reached _outbound yet when HandleInterrupt runs, so every - // later fragment must be stopped by the per-update check inside RunTurnAsync's own loop. - // AFrameAlreadyInsideTheWriteLoop_IsNotFollowedByAnotherAfterAnInterrupt below covers the - // case this test's timing cannot reach: a fragment that already reached the channel before - // the interrupt did, sitting there while the write loop is genuinely busy elsewhere. - using BlockingChatClient reply = new("one two three four five six seven eight"); - EventObservedLoggerProvider capture = new("InterruptReceived"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-race")); - await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); - - try - { - _ = await relay.ReadFrameAsync().WaitAsync(bounded.Token); - await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Interrupt("one", durationMs: 300)); - - // The same reason as the test above: the gate must not open until the connection - // has actually raised the turn id, or this test would be timing the delivery of - // one WebSocket frame rather than the guard this task exists to prove. - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported the barge-in within ten seconds."); - } - } - finally - { - // Release every remaining update. None of them may reach the socket. This sits in a - // finally so a failed assertion above still lets the fake client's task finish rather - // than leaving it, and the host's shutdown, blocked on a gate nobody will ever open. - reply.Release(); - } - - List later = await relay.ReadFramesForAsync(TimeSpan.FromMilliseconds(400)); - Assert.DoesNotContain(later, frame => frame["type"]!.GetValue() == "text"); - } - - [Fact(Timeout = 30_000)] - public async Task AnItemAlreadyQueuedBehindAStuckSend_IsRemovedByTheUnconditionalDrain() - { - // This does not exercise either id-based guard — not RunTurnAsync's own per-update check, - // and not the write loop's >= comparison in front of its one SendAsync call. Both stay - // disabled and this still passes, because HandleInterrupt's own drain — the unconditional - // `while (_outbound.Reader.TryRead(out _)) { }` that runs before either guard is ever - // reached — already removes the item this test cares about while it is still sitting in - // the channel, before either comparison gets a chance to matter. What this proves is that - // the drain reaches an item already queued, not one still in flight through a guard: a - // single, deliberately huge fragment is the whole reply, so nothing else competes for it. - // The write loop dequeues it almost immediately and calls SendAsync, and that call has no - // choice but to block on genuine TCP backpressure, because nothing reads this socket until - // this test says so. While it is stuck there, the turn's own closing token queues up right - // behind it — still sitting in _outbound, not yet dequeued, when the interrupt fires. The - // drain reaches it there and removes it. Only once this test finally reads does the huge - // fragment complete and arrive, already committed to the wire before the interrupt ever - // happened. Nothing may follow it: not the closing token the drain already removed, and - // not anything else, even though the write loop spent this whole test genuinely busy - // rather than idle when the interrupt landed. - // - // FakeRelayClient cannot prove this: its own pump reads the socket continuously in the - // background from the moment it connects, for every other test's benefit, which means - // nothing is ever actually stuck waiting on a reader through it — this test's first attempt - // used it, held a fixed delay, and failed intermittently exactly because of that pump - // draining the "stuck" frame in the background the whole time. A raw ClientWebSocket here - // is what makes the block real: the OS send buffer this connection can autotune to (4 MiB, - // confirmed via /proc/sys/net/ipv4/tcp_wmem on the machine this was verified on) cannot - // absorb an 8 MiB message with no reader draining it, so SendAsync has no choice but to - // wait. There is still no purely observable signal for "the write loop is now stuck inside - // that wait," so the fixed delay below stands in for one, sized to outlast a same-process - // production of one string and two channel writes many times over. - string hugeFragment = new('x', 8 * 1024 * 1024); - using FragmentingChatClient reply = new(hugeFragment); - EventObservedLoggerProvider capture = new("InterruptReceived"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - - using ClientWebSocket socket = new(); - await socket.ConnectAsync(host.Address, TestContext.Current.CancellationToken); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - await SendRawAsync(socket, RelayFrames.Setup(conversationSessionId: "conversation-held")); - await SendRawAsync(socket, RelayFrames.Prompt("hi", last: true)); - - await Task.Delay(300, TestContext.Current.CancellationToken); - - try - { - await SendRawAsync(socket, RelayFrames.Interrupt("nothing played", durationMs: 0)); - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported the barge-in within ten seconds."); - } - - try - { - string stuck = await ReceiveOneMessageAsync(socket, bounded.Token); - JsonNode node = JsonNode.Parse(stuck)!; - Assert.Equal("text", node["type"]!.GetValue()); - Assert.False(node["last"]!.GetValue()); - Assert.Equal(hugeFragment, node["token"]!.GetValue()); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail( - "the fragment already committed to the wire before the interrupt never arrived " - + "within ten seconds — it may not have been stuck in the write loop at all."); - } - - // Cancelling this receive is the proof that nothing else arrives in the window: it aborts - // the socket, which is fine, because this is the last thing this test does with it. - using CancellationTokenSource window = new(TimeSpan.FromMilliseconds(400)); - try - { - string extra = await ReceiveOneMessageAsync(socket, window.Token); - Assert.Fail($"an unexpected frame reached the relay after the interrupt: {extra}"); - } - catch (OperationCanceledException) when (window.IsCancellationRequested) - { - // Silence is the pass: nothing followed the frame already in flight. - } - } - - /// Sends one frame as one WebSocket message, bypassing 's own pump. - private static Task SendRawAsync(ClientWebSocket socket, string json) - { - return socket.SendAsync( - Encoding.UTF8.GetBytes(json), - WebSocketMessageType.Text, - endOfMessage: true, - TestContext.Current.CancellationToken); - } - - /// Reads exactly one message off a raw socket, reassembling it from as many fragments as it takes. - private static async Task ReceiveOneMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken) - { - byte[] buffer = new byte[64 * 1024]; - using MemoryStream message = new(); - WebSocketReceiveResult result; - do - { - result = await socket.ReceiveAsync(buffer, cancellationToken); - if (result.MessageType == WebSocketMessageType.Close) - { - throw new InvalidOperationException("the host closed the socket before sending a frame."); - } - - message.Write(buffer, 0, result.Count); - } - while (!result.EndOfMessage); - - return Encoding.UTF8.GetString(message.ToArray()); - } - - [Fact(Timeout = 30_000)] - public async Task AnInterruptWithNoTurnRunning_ChangesNothing() - { - using FragmentingChatClient reply = new("hello"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - await relay.SendAsync(RelayFrames.Setup()); - await relay.SendAsync(RelayFrames.Interrupt("nothing played", durationMs: 10)); - await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); - - try - { - Assert.Equal("hello", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the turn after the ignored interrupt never finished within ten seconds."); - } - } - - [Fact(Timeout = 30_000)] - public async Task AnInterruptWithNoUtteranceUntilInterrupt_IsRefusedAndTheConversationContinues() - { - // Section 7.1's rule for an unknown frame type applies here too: a frame the vendor got - // wrong must not drop the conversation. RelayFrames.Interrupt always emits both fields, so a raw - // frame is sent by hand here, missing utteranceUntilInterrupt entirely — the one shape - // HandleInterrupt's own guard exists to survive, since System.Text.Json deserializes a - // missing non-nullable string as null rather than throwing, and ConversationSession.Interrupt - // itself would otherwise take an ArgumentNullException straight into the read loop. - using FragmentingChatClient reply = new("hello"); - EventObservedLoggerProvider capture = new("MalformedInterruptFrame"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - await relay.SendAsync(RelayFrames.Setup()); - await relay.SendRawAsync(/*lang=json,strict*/ """{"type":"interrupt","durationUntilInterruptMs":10}"""); - - try - { - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported refusing the malformed interrupt within ten seconds."); - } - - await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); - - try - { - Assert.Equal("hello", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the turn after the refused interrupt never finished within ten seconds."); - } - } - - [Fact(Timeout = 30_000)] - public async Task AnInterruptWithANegativeDuration_IsRefusedAndTheConversationContinues() - { - // The same guard, the other malformed shape it exists for: System.Text.Json enforces no - // range check on durationUntilInterruptMs, so a negative value would otherwise reach - // ConversationSession.Interrupt's own ArgumentOutOfRangeException uncaught and take the read loop - // down with it. D28 forbids clamping it into something plausible instead of refusing it. - using FragmentingChatClient reply = new("hello"); - EventObservedLoggerProvider capture = new("MalformedInterruptFrame"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - await relay.SendAsync(RelayFrames.Setup()); - await relay.SendRawAsync( - /*lang=json,strict*/ - """{"type":"interrupt","utteranceUntilInterrupt":"hello","durationUntilInterruptMs":-5}"""); - - try - { - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported refusing the malformed interrupt within ten seconds."); - } - - await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); - - try - { - Assert.Equal("hello", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the turn after the refused interrupt never finished within ten seconds."); - } - } - - [Fact(Timeout = 30_000)] - public async Task ASecondFinalPromptDuringATurn_RunsAfterTheFirstTurnEnds() - { - // The caller can finish a second sentence before the agent speaks. There is no interrupt - // frame, so there is no heard text, and IConversationPort throws on a second turn. Holding - // one prompt keeps the caller's words; throwing would drop the conversation. - // - // reply.Conversations == 2 alone cannot tell the held path from an accident: if the release below - // happened to win the race against the server actually dispatching "two" while the first - // turn was still running, "two" would just start its own ordinary second turn instead of - // being held — and Calls would still read 2, the same as it would against code with no - // pending-prompt feature at all. Waiting for PromptHeld closes that gap: it only fires from - // inside the branch that holds a prompt, so seeing it is proof this is the path that ran. - // - // BlockingChatClient's gate ignores cancellation, so a defect in the pending-prompt chain - // would hang this test rather than fail it red, hence the deadline below. - using BlockingChatClient reply = new("first reply"); - EventObservedLoggerProvider capture = new("PromptHeld"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup()); - await relay.SendAsync(RelayFrames.Prompt("one", last: true)); - - try - { - await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Prompt("two", last: true)); - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported holding the second prompt within ten seconds."); - } - } - finally - { - reply.Release(); - } - - try - { - _ = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); // the first turn - _ = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); // the held one - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the held prompt never ran its turn within ten seconds."); - } - - Assert.Equal(2, reply.Calls); - } - - [Fact(Timeout = 30_000)] - public async Task AThirdFinalPromptDuringATurn_IsDroppedAndLoggedOnce() - { - // Item 6a of section 11 draws the line at one held prompt. This proves the line actually - // holds: a third and a fourth final prompt, arriving while one is already held, both run no - // turn of their own, and PendingPromptDropped — which the connection only ever logs once - // for the conversation — fires exactly once despite two drops, not once per dropped frame. - using BlockingChatClient reply = new("first reply"); - EventObservedLoggerProvider heldCapture = new("PromptHeld"); - EventObservedLoggerProvider droppedCapture = new("PendingPromptDropped"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => - { - _ = logging.AddProvider(heldCapture); - _ = logging.AddProvider(droppedCapture); - }); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup()); - await relay.SendAsync(RelayFrames.Prompt("one", last: true)); - - try - { - await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Prompt("two", last: true)); - await heldCapture.Observed.WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Prompt("three", last: true)); - await droppedCapture.Observed.WaitAsync(bounded.Token); - - // A fourth cannot raise a second log line to wait on — the flag that guards it - // never resets. Releasing the gate right after SendAsync returns would only prove - // the bytes left this client, the same gap every other wait in this file closes: - // reply.Release() below unblocks turn one, which can cascade through - // RunPendingPrompt and clear _pendingPrompt for the held "two" before "four" has - // even reached the read loop — and a "four" that arrives to find nothing pending - // is held for a turn of its own instead of dropped, rather than proving anything - // about a second drop. This waits for the read loop to have plainly had enough - // time to reach and drop a small already-received frame, which needs nothing past - // dispatch and a lock acquisition, before the gate opens. - await relay.SendAsync(RelayFrames.Prompt("four", last: true)); - await Task.Delay(200, TestContext.Current.CancellationToken); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported holding or dropping a prompt within ten seconds."); - } - } - finally - { - reply.Release(); - } - - try - { - _ = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); // "one" - _ = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); // the held "two" - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the held prompt never ran its turn within ten seconds."); - } - - Assert.Equal(2, reply.Calls); - Assert.Equal(1, droppedCapture.Count); - } - - [Fact(Timeout = 30_000)] - public async Task ATurnAfterABargeIn_AnswersNormally() - { - // Item 6a is half proved by AfterAnInterrupt_NoFurtherTextFrameReachesTheRelay: a barge-in - // ends one turn cleanly. The other half — the conversation itself goes on — needs its own proof, - // because nothing else here shows the caller can speak again and get a real answer rather - // than silence or a leftover interrupted reply. - using BlockingChatClient reply = new("hello there caller"); - EventObservedLoggerProvider capture = new("InterruptReceived"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => logging.AddProvider(capture)); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-continues")); - await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); - - try - { - _ = await relay.ReadFrameAsync().WaitAsync(bounded.Token); - await reply.WaitUntilStreamingAsync().WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Interrupt("hello", durationMs: 200)); - await capture.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported the barge-in within ten seconds."); - } - } - finally - { - reply.Release(); - } - - ConversationSession? session = await host.FindSessionAsync("conversation-continues"); - Assert.NotNull(session); - - TurnResult firstTurn = await WaitForTurnAsync(session!); - Assert.Equal(TimeSpan.FromMilliseconds(200), firstTurn.InterruptedAfter); - - await relay.SendAsync(RelayFrames.Prompt("go on", last: true)); - - try - { - List tokens = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); - Assert.Equal("hello there caller", string.Concat(tokens)); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the turn after the barge-in never finished within ten seconds."); - } - - Assert.Null(session!.LastTurn!.InterruptedAfter); - } - - [Fact(Timeout = 30_000)] - public async Task AnInterruptWhileAHeldTurnHasSaidNothing_LeavesThatTurnFreeToSpeak() - { - // Telnyx paces the audio, so turn one is still playing when RunPendingPrompt starts turn - // two inside turn one's own finally. A barge-in that marks the turn running *now* would - // silence turn two, drop its closing frame, and leave the caller listening to dead air. - // The interrupt belongs to the turn whose output the caller was actually hearing. - using HeldPromptChatClient reply = new("first reply", "second reply"); - EventObservedLoggerProvider held = new("PromptHeld"); - EventObservedLoggerProvider interrupted = new("InterruptReceived"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => - { - _ = logging.AddProvider(held); - _ = logging.AddProvider(interrupted); - }); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-held-turn")); - await relay.SendAsync(RelayFrames.Prompt("one", last: true)); - - try - { - await reply.WaitUntilFirstTurnStreamingAsync().WaitAsync(bounded.Token); - - // The second sentence lands while turn one still runs, so it is held rather than - // started, which is the only way to reach the two-turn overlap this test needs. - await relay.SendAsync(RelayFrames.Prompt("two", last: true)); - await held.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported holding the second prompt within ten seconds."); - } - } - finally - { - reply.ReleaseFirstTurn(); - } - - try - { - // Turn one's whole reply, closing frame included, is on the wire before the barge-in. - List first = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); - Assert.Equal("first reply", string.Concat(first)); - - // Turn two is now running and has produced nothing. The caller is still hearing turn one. - await reply.SecondTurnStarted.Task.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the held turn never started within ten seconds."); - } - - try - { - await relay.SendAsync(RelayFrames.Interrupt("first", durationMs: 640)); - await interrupted.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported the barge-in within ten seconds."); - } - finally - { - reply.ReleaseSecondTurn(); - } - - try - { - List second = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); - Assert.Equal("second reply", string.Concat(second)); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail( - "the held turn's answer never reached the relay within ten seconds; the barge-in " - + "silenced a turn the caller had not heard."); - } - } - - [Fact(Timeout = 30_000)] - public async Task AnInterruptWhileAHeldTurnHasOnlyThoughtAloud_StillAmendsTheTurnTheCallerHeard() - { - // The sibling test above holds turn two before it produces anything at all, and both sides - // of the seam then agree on their own: the connection marks _spokenTurnId, which still names - // turn one, and ConversationSession finds its own run not audible. This test closes the gap between - // those two answers. A run becomes audible to ConversationSession at its first piece of *content*, - // and content is not a word: a tool call, a tool result, or a line of reasoning makes turn - // two audible to the core while not one syllable of it has reached the relay. The connection - // still marks turn one, correctly, and still lets turn two speak — but ConversationSession, asked - // nothing about which turn was heard, would cut turn two at its first thought and write the - // caller's heard text onto a turn nobody heard. cutsRunningTurn is the answer the connection - // already has, carried across the seam so the core stops guessing. - using HeldPromptChatClient reply = new("first reply", "second reply") - { - SecondTurnOpensWithUnspokenContent = true, - }; - EventObservedLoggerProvider held = new("PromptHeld"); - EventObservedLoggerProvider interrupted = new("InterruptReceived"); - await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( - TelnyxRelayTurnTests.PolicyYaml, - reply, - logging: logging => - { - _ = logging.AddProvider(held); - _ = logging.AddProvider(interrupted); - }); - await using FakeRelayClient relay = await host.ConnectAsync(); - - using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); - using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( - deadline.Token, TestContext.Current.CancellationToken); - - try - { - await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "conversation-thinking-turn")); - await relay.SendAsync(RelayFrames.Prompt("one", last: true)); - - try - { - await reply.WaitUntilFirstTurnStreamingAsync().WaitAsync(bounded.Token); - - await relay.SendAsync(RelayFrames.Prompt("two", last: true)); - await held.Observed.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported holding the second prompt within ten seconds."); - } - } - finally - { - reply.ReleaseFirstTurn(); - } - - ConversationSession? session = await host.FindSessionAsync("conversation-thinking-turn"); - Assert.NotNull(session); - - try - { - // Turn one's whole reply, closing frame included, is on the wire before the barge-in. - Assert.Equal("first reply", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); - - // Turn two is now running and has thought aloud once. The core calls it audible; the - // caller has heard nothing of it, because reasoning carries no text frame. - await reply.SecondTurnStarted.Task.WaitAsync(bounded.Token); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the held turn never reached its first thought within ten seconds."); - } - - TurnResult? first = session!.LastTurn; - Assert.NotNull(first); - Assert.Null(first!.InterruptedAfter); - - try - { - await relay.SendAsync(RelayFrames.Interrupt("first", durationMs: 640)); - await interrupted.Observed.WaitAsync(bounded.Token); - - // Read before the gate opens, while turn one is still the turn that finished last. D28: - // both values are the ones the vendor reported, and nothing here estimated either. - Assert.Equal(first.TurnIndex, session.LastTurn!.TurnIndex); - Assert.Equal("first", session.LastTurn.ReplyText); - Assert.Equal(TimeSpan.FromMilliseconds(640), session.LastTurn.InterruptedAfter); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail("the connection never reported the barge-in within ten seconds."); - } - finally - { - reply.ReleaseSecondTurn(); - } - - try - { - List second = await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token); - Assert.Equal("second reply", string.Concat(second)); - } - catch (OperationCanceledException) when (deadline.IsCancellationRequested) - { - Assert.Fail( - "the held turn's answer never reached the relay within ten seconds; the barge-in " - + "cut a turn the caller had only heard silence from."); - } - - // Turn two ran to its own end. Nothing wrote the heard text onto it. - Assert.Equal("second reply", session.LastTurn!.ReplyText); - Assert.Null(session.LastTurn.InterruptedAfter); - } - - /// Polls for the turn a barge-in ends, bounded the same way TelnyxRelayHost's own waits are. - private static async Task WaitForTurnAsync(ConversationSession session) - { - for (int attempt = 0; attempt < 1_000; attempt++) - { - if (session.LastTurn is { } turn) - { - return turn; - } - - await Task.Delay(10, TestContext.Current.CancellationToken); - } - - throw new TimeoutException("the interrupted turn never finished within ten seconds."); - } - } -} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs new file mode 100644 index 00000000..5db21c00 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConnectionFaultTests.cs @@ -0,0 +1,237 @@ +using System.Net.WebSockets; +using AgentCore.Application.Audit; +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.DependencyInjection.Startup; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// How TelnyxRelayConnection tears down when the voice loop, the relay, or the close itself fails. + public sealed class TelnyxRelayConnectionFaultTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // TelnyxRelayConnection.DetermineCloseStatus: a fault in the voice loop closes the socket as a read-loop fault does. + [Fact(Timeout = 30_000)] + public async Task AVoiceLoopThatFaults_ClosesWithInternalServerError() + { + using FragmentingChatClient reply = new("hello"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + configure: options => options.UseConversationSessions(_ => new UnopenableConversationSessions())); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-unopenable")); + await harness.Connection.WaitAsync(Ct); + + Assert.Equal(WebSocketCloseStatus.InternalServerError, harness.Socket.CloseSent?.Status); + } + + // TelnyxRelayConnection.ClassifyTelnyxFault: a relay that drops with no close frame is the vendor's doing, so the + // conversation ends as a hang-up and no defect is logged. + [Fact(Timeout = 30_000)] + public async Task ARelayThatDropsWithNoCloseFrame_EndsAsACallerHangUpAndLogsNoDefect() + { + using FragmentingChatClient reply = new("hello"); + EventObservedLoggerProvider defect = new("ReadLoopFaulted"); + EventObservedLoggerProvider dropped = new("ConversationDroppedWithNoCloseFrame"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(defect).AddProvider(dropped)); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-dropped")); + harness.Socket.FailReceive(new WebSocketException(WebSocketError.ConnectionClosedPrematurely)); + await harness.Connection.WaitAsync(Ct); + + Assert.Equal(WebSocketCloseStatus.NormalClosure, harness.Socket.CloseSent?.Status); + Assert.Equal("caller.hangup", await EndReasonAsync(harness, "conversation-dropped")); + Assert.True(dropped.Observed.IsCompleted); + Assert.False(defect.Observed.IsCompleted, "a dropped relay was logged as a read-loop defect."); + } + + // A drop, or a close handshake that already ran, can reach the write loop before the read loop: it ends as the read + // loop's drop does, as a hang-up with no defect logged. A send that fails any other way stays a fault. + [Theory(Timeout = 30_000)] + [InlineData(WebSocketState.Aborted)] + [InlineData(WebSocketState.Closed)] + public async Task ASendThatFindsTheSocketGone_EndsAsACallerHangUpAndLogsNoDefect(WebSocketState gone) + { + using FragmentingChatClient reply = new("hello there caller"); + EventObservedLoggerProvider defect = new("WriteLoopFaulted"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(defect)); + harness.Socket.GoneOnSend(gone); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-gone-on-send")); + harness.Socket.Queue(RelayFrames.Prompt("hi", last: true)); + await harness.Connection.WaitAsync(Ct); + + Assert.Equal("caller.hangup", await EndReasonAsync(harness, "conversation-gone-on-send")); + Assert.False(defect.Observed.IsCompleted, "a socket that was already gone was logged as a write-loop defect."); + } + + // A send the teardown cancelled aborts a managed socket under the close that follows. There is nothing left to + // close, and that is not a fault. + [Fact(Timeout = 30_000)] + public async Task ACloseThatFindsTheSocketAbortedUnderIt_LogsNoFault() + { + using FragmentingChatClient reply = new("hello"); + EventObservedLoggerProvider fault = new("CloseFaulted"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(fault)); + harness.Socket.AbortDuringClose(); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-aborted-close")); + harness.Socket.QueueClose(); + await harness.Connection.WaitAsync(Ct); + + Assert.Equal("caller.hangup", await EndReasonAsync(harness, "conversation-aborted-close")); + Assert.False(fault.Observed.IsCompleted, "a close on a socket already aborted was logged as a fault."); + } + + // P7x: the relay stops reading, so a reply fills the outbound queue, and then the relay drops. Teardown must not + // wait on a speech blocked on that queue: the socket is gone and nothing queued can be sent. The voice clock is + // fake, so the interruption backstop never frees it; only the real close timeout would. + [Fact(Timeout = 30_000)] + public async Task ADropWhileAReplyIsBlockedOnAFullQueue_TearsDownWithoutWaitingOnThatReply() + { + FakeTimeProvider clock = new(DateTimeOffset.UnixEpoch); + using FragmentingChatClient reply = new(string.Join(' ', Enumerable.Range(0, 600).Select(word => "w" + word))); + EventObservedLoggerProvider timedOut = new("TeardownTimedOut"); + EventObservedLoggerProvider stuck = new("SpeechNotDoneAfterInterruption"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(timedOut).AddProvider(stuck), + configure: options => options.TimeProvider = clock); + harness.Socket.StallSends(); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-stalled-drop")); + harness.Socket.Queue(RelayFrames.Prompt("hi", last: true)); + + // 600 words against a queue of 256 and one stalled send: the reply is blocked on the full queue by now. + await harness.Socket.SendStalled.WaitAsync(Ct); + await reply.Streamed.WaitAsync(Ct); + + harness.Socket.FailReceive(new WebSocketException(WebSocketError.ConnectionClosedPrematurely)); + await harness.Connection.WaitAsync(Ct); + + Assert.False(timedOut.Observed.IsCompleted, "teardown waited out its close timeout on a reply blocked on the queue."); + Assert.False(stuck.Observed.IsCompleted); + Assert.Equal("caller.hangup", await EndReasonAsync(harness, "conversation-stalled-drop")); + } + + // Section 7.1: teardown never throws out of the request handler, so a close that fails still ends the conversation. + [Fact(Timeout = 30_000)] + public async Task ACloseThatThrows_StillEndsTheConversationAndReleasesItsSession() + { + using FragmentingChatClient reply = new("hello"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply); + harness.Socket.FailClose(new InvalidOperationException("the close failed.")); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-close-faulted")); + harness.Socket.QueueClose(); + await harness.Connection.WaitAsync(Ct); + + Assert.Equal("caller.hangup", await EndReasonAsync(harness, "conversation-close-faulted")); + Assert.Null(await harness.Services.GetRequiredService().Sessions + .TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-close-faulted", Ct)); + } + + // VoiceConversationLoop.KeepSessionAliveAsync: a store that cannot be read on a turn is logged, and the turn still runs. + [Fact(Timeout = 30_000)] + public async Task AStoreThatCannotBeTouchedOnATurn_LeavesTheConversationRunning() + { + using FragmentingChatClient reply = new("hello"); + EventObservedLoggerProvider touch = new("SessionTouchFaulted"); + await using RelayConnectionHarness harness = await RelayConnectionHarness.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(touch), + configure: options => options.UseConversationSessions( + factories => new UntouchableConversationSessions(factories[SingleEntrySessionFactories.MainEntry]))); + + harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-untouchable")); + harness.Socket.Queue(RelayFrames.Prompt("hi", last: true)); + harness.Socket.QueueClose(); + await harness.Connection.WaitAsync(Ct); + + Assert.True(touch.Observed.IsCompleted); + Assert.Equal(WebSocketCloseStatus.NormalClosure, harness.Socket.CloseSent?.Status); + } + + private static async Task EndReasonAsync(RelayConnectionHarness harness, string conversationId) + { + QueuedAuditSink queue = Assert.IsType(harness.Services.GetRequiredService()); + await queue.FlushAsync(Ct); + InMemoryAuditSink sink = Assert.IsType(harness.Services.GetRequiredService().Store); + AuditEvent ended = Assert.Single( + sink.EventsOf(conversationId), item => item.Kind == AuditEventKind.ConversationEnded); + return ended.Payload[AuditPayloadKeys.EndReason]; + } + + /// Sessions that open and close normally, and throw on every read. + private sealed class UntouchableConversationSessions(IConversationSessionFactory factory) : IConversationSessions, IDisposable + { + private readonly InMemoryConversationSessions _inner = new( + SingleEntrySessionFactories.Of(factory), + InMemoryConversationSessions.DefaultIdleTimeout, + TimeProvider.System); + + public void Dispose() + { + _inner.Dispose(); + } + + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) + { + return _inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); + } + + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return ValueTask.FromException(new InvalidOperationException("the store is gone.")); + } + + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return _inner.CloseAsync(entry, conversationId, cancellationToken); + } + } + + /// Sessions a conversation can never be opened in. + private sealed class UnopenableConversationSessions : IConversationSessions + { + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) + { + return ValueTask.FromException(new InvalidOperationException("the store is gone.")); + } + + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(null); + } + + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs index d1548775..163b9490 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayConversationEndTests.cs @@ -4,6 +4,7 @@ using AgentCore.Application.Configuration.Compilation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; using AgentCore.Application.Ports; using AgentCore.Application.Runtime; using AgentCore.Application.Sessions.Memory; @@ -229,7 +230,7 @@ public async Task AClockThatThrowsWhileTheChainCloses_StillTearsDownAndStillRele await WaitForTeardownAsync(harness); Assert.True(harness.Connection.IsCompletedSuccessfully); - Assert.Null(await Sessions(harness).TryGetAsync("conversation-broken-clock", TestContext.Current.CancellationToken)); + Assert.Null(await Sessions(harness).TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-broken-clock", TestContext.Current.CancellationToken)); } [Fact(Timeout = 30_000)] @@ -249,7 +250,7 @@ public async Task EndConversation_HangUp_FlushesTranscriptBeforeSessionRemoval() TelnyxRelayTurnTests.PolicyYaml, reply, relay: options => options.CloseTimeout = TimeSpan.FromSeconds(30), - configure: options => options.UseConversationSessions((_, _) => sessions)); + configure: options => options.UseConversationSessions(_ => sessions)); harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-flush")); harness.Socket.Queue(RelayFrames.Prompt("when does my order ship?", last: true)); @@ -269,7 +270,7 @@ public async Task EndConversation_HangUp_FlushesTranscriptBeforeSessionRemoval() Assert.True( sessions.TranscriptLandedAtClose, "the close returned while store 1 still owed the conversation its last turn."); - Assert.Null(await sessions.TryGetAsync("conversation-flush", TestContext.Current.CancellationToken)); + Assert.Null(await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-flush", TestContext.Current.CancellationToken)); } [Fact(Timeout = 30_000)] @@ -289,7 +290,7 @@ public async Task StartConversation_SecondSetupFrame_FlushesTheReplacedTranscrip TelnyxRelayTurnTests.PolicyYaml, reply, relay: options => options.CloseTimeout = TimeSpan.FromSeconds(30), - configure: options => options.UseConversationSessions((_, _) => sessions)); + configure: options => options.UseConversationSessions(_ => sessions)); harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-first")); harness.Socket.Queue(RelayFrames.Prompt("when does my order ship?", last: true)); @@ -327,7 +328,7 @@ public async Task EndConversation_StoreThatThrowsOnTheClose_LogsItAndStillTearsD TelnyxRelayTurnTests.PolicyYaml, reply, logging: logging => logging.AddProvider(capture), - configure: options => options.UseConversationSessions((_, _) => sessions)); + configure: options => options.UseConversationSessions(_ => sessions)); harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-store-faulted")); @@ -362,7 +363,7 @@ public async Task EndConversation_StoreThatNeverAnswersTheClose_TimesOutRatherTh reply, logging: logging => logging.AddProvider(capture), relay: options => options.CloseTimeout = TimeSpan.FromSeconds(1), - configure: options => options.UseConversationSessions((_, _) => sessions)); + configure: options => options.UseConversationSessions(_ => sessions)); harness.Socket.Queue(RelayFrames.Setup(conversationSessionId: "conversation-store-hung")); @@ -415,7 +416,7 @@ private static ConversationSessionFactory TranscriptBackedSessions( RoutingChatClientFactory chatClients = new(reply); CompiledAgent compiled = ConfigurationCompiler.CompileAll( document, - new AgentCompilationContext(chatClients) { ConversationStore = transcript })["main"]; + new AgentCompilationContext(chatClients) { ConversationStore = transcript })[SingleEntrySessionFactories.MainEntry]; return new ConversationSessionFactory( compiled, @@ -475,7 +476,7 @@ private static async Task WaitForSessionAsync(RelayConnectionHarness harness, st { for (int attempt = 0; attempt < 200; attempt++) { - if (await Sessions(harness).TryGetAsync(conversationId, TestContext.Current.CancellationToken) is not null) + if (await Sessions(harness).TryGetAsync(SingleEntrySessionFactories.MainEntry, conversationId, TestContext.Current.CancellationToken) is not null) { return; } @@ -494,7 +495,7 @@ private static async Task WaitForCompletedConversationAsync { for (int attempt = 0; attempt < 400; attempt++) { - if (await Sessions(harness).TryGetAsync(conversationId, TestContext.Current.CancellationToken) + if (await Sessions(harness).TryGetAsync(SingleEntrySessionFactories.MainEntry, conversationId, TestContext.Current.CancellationToken) is { IsComplete: true } session) { return session; @@ -548,7 +549,7 @@ private static InMemoryAuditSink Sink(RelayConnectionHarness harness) /// Reads back the live sessions. private static IConversationSessions Sessions(RelayConnectionHarness harness) { - return harness.Services.GetRequiredService().ForSessions("main"); + return harness.Services.GetRequiredService().Sessions; } } @@ -589,10 +590,17 @@ public override DateTimeOffset GetUtcNow() /// The live sessions, with a note of what store 1 had done by the time a close returned. /// internal sealed class OrderedConversationSessions(IConversationSessionFactory factory, Func transcriptLanded) - : IConversationSessions + : IConversationSessions, IDisposable { - private readonly InMemoryConversationSessions _inner = - new(factory, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + private readonly InMemoryConversationSessions _inner = new( + SingleEntrySessionFactories.Of(factory), + InMemoryConversationSessions.DefaultIdleTimeout, + TimeProvider.System); + + public void Dispose() + { + _inner.Dispose(); + } private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -603,21 +611,21 @@ internal sealed class OrderedConversationSessions(IConversationSessionFactory fa public bool? TranscriptLandedAtClose { get; private set; } /// - public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default) + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) { - return _inner.OpenAsync(conversationId, cancellationToken); + return _inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); } /// - public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { - return _inner.TryGetAsync(conversationId, cancellationToken); + return _inner.TryGetAsync(entry, conversationId, cancellationToken); } /// - public async ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default) + public async ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { - await _inner.CloseAsync(conversationId, cancellationToken); + await _inner.CloseAsync(entry, conversationId, cancellationToken); TranscriptLandedAtClose ??= transcriptLanded(); _ = _closed.TrySetResult(); } @@ -631,28 +639,35 @@ public async ValueTask CloseAsync(string conversationId, CancellationToken cance /// the two for teardown to catch: a synchronous throw out of a method lands /// at the conversation site, not on the returned task. /// - internal sealed class FaultingConversationSessions(IConversationSessionFactory factory, Exception fault) : IConversationSessions + internal sealed class FaultingConversationSessions(IConversationSessionFactory factory, Exception fault) : IConversationSessions, IDisposable { - private readonly InMemoryConversationSessions _inner = - new(factory, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + private readonly InMemoryConversationSessions _inner = new( + SingleEntrySessionFactories.Of(factory), + InMemoryConversationSessions.DefaultIdleTimeout, + TimeProvider.System); + + public void Dispose() + { + _inner.Dispose(); + } /// Gets whether teardown ever reached the close. public bool CloseAttempted { get; private set; } /// - public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default) + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) { - return _inner.OpenAsync(conversationId, cancellationToken); + return _inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); } /// - public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { - return _inner.TryGetAsync(conversationId, cancellationToken); + return _inner.TryGetAsync(entry, conversationId, cancellationToken); } /// - public ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { CloseAttempted = true; throw fault; @@ -660,23 +675,30 @@ public ValueTask CloseAsync(string conversationId, CancellationToken cancellatio } /// Sessions whose close never answers until a test releases it. - internal sealed class HangingConversationSessions(IConversationSessionFactory factory) : IConversationSessions + internal sealed class HangingConversationSessions(IConversationSessionFactory factory) : IConversationSessions, IDisposable { - private readonly InMemoryConversationSessions _inner = - new(factory, InMemoryConversationSessions.DefaultIdleTimeout, TimeProvider.System); + private readonly InMemoryConversationSessions _inner = new( + SingleEntrySessionFactories.Of(factory), + InMemoryConversationSessions.DefaultIdleTimeout, + TimeProvider.System); + + public void Dispose() + { + _inner.Dispose(); + } private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); /// - public ValueTask OpenAsync(string? conversationId, CancellationToken cancellationToken = default) + public ValueTask GetOrOpenAsync(string entry, string? conversationId, ConversationSessionState? state, CancellationToken cancellationToken = default) { - return _inner.OpenAsync(conversationId, cancellationToken); + return _inner.GetOrOpenAsync(entry, conversationId, state, cancellationToken); } /// - public ValueTask TryGetAsync(string conversationId, CancellationToken cancellationToken = default) + public ValueTask TryGetAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { - return _inner.TryGetAsync(conversationId, cancellationToken); + return _inner.TryGetAsync(entry, conversationId, cancellationToken); } /// @@ -685,10 +707,10 @@ public ValueTask OpenAsync(string? conversationId, Cancella /// there, so a store that honoured a token would prove /// nothing about the bound teardown puts on the wait itself. /// - public async ValueTask CloseAsync(string conversationId, CancellationToken cancellationToken = default) + public async ValueTask CloseAsync(string entry, string conversationId, CancellationToken cancellationToken = default) { await _release.Task; - await _inner.CloseAsync(conversationId, CancellationToken.None); + await _inner.CloseAsync(entry, conversationId, CancellationToken.None); } /// Lets the parked close finish. diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayEndpointTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayEndpointTests.cs index 7d3519dd..db095c8c 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayEndpointTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayEndpointTests.cs @@ -76,10 +76,11 @@ public async Task MalformedJson_LogsTheRefusalAtWarningNotError() // Error level ReadLoopFaulted uses for an unhandled defect. using FragmentingChatClient reply = new("hello"); EventObservedLoggerProvider capture = new("RelayProtocolViolation"); + EventObservedLoggerProvider defect = new("ReadLoopFaulted"); await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( TelnyxRelayTurnTests.PolicyYaml, reply, - logging: logging => logging.AddProvider(capture)); + logging: logging => logging.AddProvider(capture).AddProvider(defect)); await using FakeRelayClient relay = await host.ConnectAsync(); await relay.SendRawAsync("{ this is not JSON"); @@ -97,7 +98,11 @@ public async Task MalformedJson_LogsTheRefusalAtWarningNotError() Assert.Fail("the connection never logged the protocol violation within ten seconds."); } + // The close goes out only once teardown has logged every loop's fault, so a defect line would be in by now. + _ = await relay.ReadCloseAsync(); + Assert.Equal(LogLevel.Warning, capture.Level); + Assert.False(defect.Observed.IsCompleted, "the protocol violation was also logged as a read-loop defect."); } [Fact(Timeout = 30_000)] diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs new file mode 100644 index 00000000..e5a7846e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayFinalPromptCutTests.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// A final prompt that lands while a step still streams stops the engine turn at once, as LiveKit cancels its + /// generation on an interruption (agent_activity.py:3811-3830), and the relay's report of what was heard + /// still settles the turn's text before the next turn starts (generation.py:748-768). + /// + public sealed class TelnyxRelayFinalPromptCutTests + { + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); + + // Less than VoiceOptions.DefaultHeardTextWait: a turn left running through the wait resumes inside it. + private static readonly TimeSpan ResumeAfter = TimeSpan.FromSeconds(1); + + [Fact(Timeout = 30_000)] + public async Task AFinalPromptMidStep_StopsTheTurnAtOnce_SoItsToolNeverRuns() + { + GatedStepChatClient model = new(); + int toolCalls = 0; + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TwoStepChatClient.Yaml, + model, + options => options.Bind(TwoStepChatClient.ToolBinding, (_, _) => + { + _ = Interlocked.Increment(ref toolCalls); + return ValueTask.FromResult("42"); + })); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "final-prompt-stop")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReadTokensUntilAsync(relay, "Hello there, "); + await model.Held.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + + await relay.SendAsync(RelayFrames.Prompt("wait", last: true)); + _ = await Task.WhenAny(model.Stopped.Task, Task.Delay(ResumeAfter, TestContext.Current.CancellationToken)); + model.Resume(); + + List rows = await StoredRows.SettleAsync(host.Services, "final-prompt-stop", 4); + Assert.Equal(0, Volatile.Read(ref toolCalls)); + Assert.DoesNotContain(model.Requests, request => request[^1].Role == ChatRole.Tool); + StoredRows.AssertTurn( + rows, + ("user", "hi", false, false), + ("assistant", "Hello there,", false, false), + ("user", "wait", false, false), + ("assistant", "Sure.", false, false)); + } + + [Fact(Timeout = 30_000)] + public async Task AFinalPromptMidStep_ThenTheRelaysReport_KeepsTheHeardText() + { + GatedStepChatClient model = new(); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "final-prompt-heard")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReadTokensUntilAsync(relay, "Hello there, "); + await model.Held.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + + await relay.SendAsync(RelayFrames.Prompt("wait", last: true)); + await model.Stopped.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + await relay.SendAsync(RelayFrames.Interrupt("Hello", durationMs: 400)); + + List rows = await StoredRows.SettleAsync(host.Services, "final-prompt-heard", 4); + StoredRows.AssertTurn( + rows, + ("user", "hi", false, false), + ("assistant", "Hello", false, false), + ("user", "wait", false, false), + ("assistant", "Sure.", false, false)); + Assert.Equal(["Hello"], model.Requests.Last().Where(message => message.Role == ChatRole.Assistant).Select(message => message.Text)); + } + + private static async Task ReadTokensUntilAsync(FakeRelayClient relay, string expected) + { + StringBuilder text = new(); + using CancellationTokenSource deadline = new(Bound); + while (text.ToString() != expected) + { + JsonNode frame = await relay.ReadFrameAsync().WaitAsync(deadline.Token); + if (frame["type"]?.GetValue() == "text") + { + _ = text.Append(frame["token"]!.GetValue()); + } + } + } + + /// + /// Step one says "Hello there, ", holds until or until its request is cancelled, then + /// says more and calls the tool. A request after a tool result, or for any later words, answers at once. + /// + private sealed class GatedStepChatClient : IChatClient + { + private readonly TaskCompletionSource _resume = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Held { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ConcurrentQueue> Requests { get; } = new(); + + public void Resume() + { + _ = _resume.TrySetResult(); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List sent = [.. messages.Where(message => message.Role != ChatRole.System)]; + Requests.Enqueue(sent); + string id = Guid.NewGuid().ToString("N"); + ChatMessage last = sent[^1]; + + if (last.Role == ChatRole.Tool) + { + yield return Text("Done.", id); + yield break; + } + + if (last.Text != "hi") + { + yield return Text("Sure.", id); + yield break; + } + + yield return Text("Hello there, ", id); + _ = Held.TrySetResult(); + try + { + await _resume.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + _ = Stopped.TrySetResult(); + throw; + } + + yield return Text("and more.", id); + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent("call_1", options!.Tools!.OfType().First().Name, new Dictionary())]) + { + ResponseId = id, + MessageId = id, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + } + + private static ChatResponseUpdate Text(string text, string id) + { + return new ChatResponseUpdate(ChatRole.Assistant, text) { ResponseId = id, MessageId = id }; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs new file mode 100644 index 00000000..44dbf934 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInputTests.cs @@ -0,0 +1,94 @@ +using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// on its own: one reader, and a read loop that is never left waiting + /// on an event nobody will read. + public sealed class TelnyxRelayInputTests + { + private const int MalformedInterruptFrame = 13; + + private static readonly RelayFrame Setup = new RelayFrame.Setup( + "session-1", "call-1", "control-1", "conversation-1", "+15550100", "+15550199", CustomParameters: null); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // IConversationInputPort.ListenAsync: one consumer for the life of the port. + [Fact] + public void ASecondListenAsync_Throws() + { + TelnyxRelayInput input = NewInput(); + _ = input.ListenAsync(Ct); + + _ = Assert.Throws(() => input.ListenAsync(Ct)); + } + + // AcceptAsync: the read loop awaits each frame's task, so a reader that stops must release every one. + [Fact(Timeout = 30_000)] + public async Task AReaderThatStops_ReleasesTheFramesItNeverRead() + { + TelnyxRelayInput input = NewInput(); + Task setup = input.AcceptAsync(Setup); + Task prompt = input.AcceptAsync(new RelayFrame.Prompt("hello", "en", Last: true)); + IAsyncEnumerator reader = input.ListenAsync(Ct).GetAsyncEnumerator(Ct); + Assert.True(await reader.MoveNextAsync()); + + await reader.DisposeAsync(); + + Assert.True(setup.IsCompleted); + Assert.True(prompt.IsCompleted); + Assert.True(input.AcceptAsync(new RelayFrame.Prompt("anyone?", "en", Last: true)).IsCompleted); + } + + [Fact] + public void AFrameAfterTheStreamEnded_IsReleasedAtOnce() + { + TelnyxRelayInput input = NewInput(); + input.Complete(); + + Assert.True(input.AcceptAsync(Setup).IsCompleted); + } + + [Fact(Timeout = 30_000)] + public async Task AnInterruptBeforeTheSetupFrame_BecomesNoEvent() + { + TelnyxRelayInput input = NewInput(); + + Task interrupt = input.AcceptAsync(new RelayFrame.Interrupt("hello", 300)); + Task setup = input.AcceptAsync(Setup); + input.Complete(); + + Assert.True(interrupt.IsCompleted); + List heard = []; + await foreach (ConversationInput item in input.ListenAsync(Ct)) + { + heard.Add(item); + } + + _ = Assert.IsType(Assert.Single(heard)); + await setup; + } + + [Fact] + public void MalformedInterrupts_AreLoggedOncePerConversation() + { + RecordingLoggerFactory logs = new(); + TelnyxRelayInput input = new(logs.CreateLogger("relay"), () => "conversation-1"); + _ = input.AcceptAsync(Setup); + + _ = input.AcceptAsync(new RelayFrame.Interrupt("hello", -1)); + _ = input.AcceptAsync(new RelayFrame.Interrupt("hello", -5)); + + _ = Assert.Single(logs.Of(MalformedInterruptFrame)); + } + + private static TelnyxRelayInput NewInput() + { + return new TelnyxRelayInput(NullLogger.Instance, () => "conversation-1"); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInterruptRefusalTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInterruptRefusalTests.cs new file mode 100644 index 00000000..3d80c123 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayInterruptRefusalTests.cs @@ -0,0 +1,127 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// Interrupts that change nothing: no turn running, or a malformed frame the vendor sent by mistake. + /// + public sealed class TelnyxRelayInterruptRefusalTests + { + [Fact(Timeout = 30_000)] + public async Task AnInterruptWithNoTurnRunning_ChangesNothing() + { + using FragmentingChatClient reply = new("hello"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TelnyxRelayTurnTests.PolicyYaml, reply); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendAsync(RelayFrames.Interrupt("nothing played", durationMs: 10)); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + + try + { + Assert.Equal("hello", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the turn after the ignored interrupt never finished within ten seconds."); + } + } + + [Fact(Timeout = 30_000)] + public async Task AnInterruptWithNoUtteranceUntilInterrupt_IsRefusedAndTheConversationContinues() + { + // Section 7.1's rule for an unknown frame type applies here too: a frame the vendor got + // wrong must not drop the conversation. RelayFrames.Interrupt always emits both fields, so a raw + // frame is sent by hand here, missing utteranceUntilInterrupt entirely — the one shape + // HandleInterrupt's own guard exists to survive, since System.Text.Json deserializes a + // missing non-nullable string as null rather than throwing, and ConversationSession.Interrupt + // itself would otherwise take an ArgumentNullException straight into the read loop. + using FragmentingChatClient reply = new("hello"); + EventObservedLoggerProvider capture = new("MalformedInterruptFrame"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(capture)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendRawAsync(/*lang=json,strict*/ """{"type":"interrupt","durationUntilInterruptMs":10}"""); + + try + { + await capture.Observed.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported refusing the malformed interrupt within ten seconds."); + } + + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + + try + { + Assert.Equal("hello", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the turn after the refused interrupt never finished within ten seconds."); + } + } + + [Fact(Timeout = 30_000)] + public async Task AnInterruptWithANegativeDuration_IsRefusedAndTheConversationContinues() + { + // The same guard, the other malformed shape it exists for: System.Text.Json enforces no + // range check on durationUntilInterruptMs, so a negative value would otherwise reach + // ConversationSession.Interrupt's own ArgumentOutOfRangeException uncaught and take the read loop + // down with it. D28 forbids clamping it into something plausible instead of refusing it. + using FragmentingChatClient reply = new("hello"); + EventObservedLoggerProvider capture = new("MalformedInterruptFrame"); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TelnyxRelayTurnTests.PolicyYaml, + reply, + logging: logging => logging.AddProvider(capture)); + await using FakeRelayClient relay = await host.ConnectAsync(); + + using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10)); + using CancellationTokenSource bounded = CancellationTokenSource.CreateLinkedTokenSource( + deadline.Token, TestContext.Current.CancellationToken); + + await relay.SendAsync(RelayFrames.Setup()); + await relay.SendRawAsync( + /*lang=json,strict*/ + """{"type":"interrupt","utteranceUntilInterrupt":"hello","durationUntilInterruptMs":-5}"""); + + try + { + await capture.Observed.WaitAsync(bounded.Token); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the connection never reported refusing the malformed interrupt within ten seconds."); + } + + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + + try + { + Assert.Equal("hello", string.Concat(await relay.ReadTextFramesUntilLastAsync().WaitAsync(bounded.Token))); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Assert.Fail("the turn after the refused interrupt never finished within ten seconds."); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs new file mode 100644 index 00000000..bf6f13c1 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepCutTests.cs @@ -0,0 +1,97 @@ +using System.Text; +using System.Text.Json.Nodes; +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// A barge-in on a reply of two steps: step one speaks and calls a tool, step two speaks after the result. + /// The history keeps the user's message, step one's words before its tool call, the finished pair, and the + /// heard part of the step the caller cut (owner ruling 2026-09-23; LiveKit adds each step's + /// forwarded_text as its own message, agent_activity.py:3843-3861). + /// + public sealed class TelnyxRelayMultiStepCutTests + { + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); + + // Probe V5_BargeInInSecondStep_KeepsBothStepsHeardText. + [Fact(Timeout = 30_000)] + public async Task BargeInDuringStepTwo_KeepsStepOneInPlaceAndTheHeardPartOfStepTwo() + { + TwoStepChatClient model = new( + ["Let me check.", TwoStepChatClient.Call], + ["Your order", TwoStepChatClient.Pause, " ships today"]); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "step-two-barge")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReadTokensUntilAsync(relay, "Let me check.Your order"); + await model.Paused.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + + // Telnyx reports the utterance of the step it was playing. + await relay.SendAsync(RelayFrames.Interrupt("Your order", durationMs: 500)); + + List rows = await StoredRows.SettleAsync(host.Services, "step-two-barge", 4); + StoredRows.AssertTurn(rows, ("user", "hi", false, false), ("assistant", "Let me check.", true, false), ("tool", "", false, true), ("assistant", "Your order", false, false)); + } + + [Fact(Timeout = 30_000)] + public async Task BargeInOnStepOneAfterItsToolRan_CutsStepOneToTheHeardWords() + { + TwoStepChatClient model = new( + ["Let me check.", TwoStepChatClient.Call], + [TwoStepChatClient.Pause, "Your order"]); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "step-one-barge")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReadTokensUntilAsync(relay, "Let me check."); + await model.Paused.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + + // Step two has said nothing, so the report names step one's utterance, still playing. + await relay.SendAsync(RelayFrames.Interrupt("Let me", durationMs: 300)); + + List rows = await StoredRows.SettleAsync(host.Services, "step-one-barge", 3); + StoredRows.AssertTurn(rows, ("user", "hi", false, false), ("assistant", "Let me", true, false), ("tool", "", false, true)); + } + + // Probe V7_CompletedMultiStepReply_KeepsBothSteps. + [Fact(Timeout = 30_000)] + public async Task CompletedTwoStepReply_KeepsBothStepsInOrder() + { + TwoStepChatClient model = new( + ["Let me check.", TwoStepChatClient.Call], + ["Your order", " ships today"]); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync(TwoStepChatClient.Yaml, model, TwoStepChatClient.BindTool); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "two-steps")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + await ReadTokensUntilAsync(relay, "Let me check.Your order ships today"); + + List rows = await StoredRows.SettleAsync(host.Services, "two-steps", 4); + StoredRows.AssertTurn(rows, ("user", "hi", false, false), ("assistant", "Let me check.", true, false), ("tool", "", false, true), ("assistant", "Your order ships today", false, false)); + } + + private static async Task ReadTokensUntilAsync(FakeRelayClient relay, string expected) + { + StringBuilder text = new(); + using CancellationTokenSource deadline = new(Bound); + while (text.ToString() != expected) + { + JsonNode frame = await relay.ReadFrameAsync().WaitAsync(deadline.Token); + if (frame["type"]?.GetValue() == "text") + { + _ = text.Append(frame["token"]!.GetValue()); + } + + Assert.StartsWith(text.ToString(), expected, StringComparison.Ordinal); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepFaultTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepFaultTests.cs new file mode 100644 index 00000000..f867ffc0 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayMultiStepFaultTests.cs @@ -0,0 +1,38 @@ +using AgentCore.Application.Transcript; +using AgentCore.AspNetCore.Tests.Fakes; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// + /// A voice reply of two steps whose second step's tool fails until the run ends: the caller hears step one's + /// words and then the fallback line, and the history keeps both, in that order (owner rulings 2026-09-23). + /// + public sealed class TelnyxRelayMultiStepFaultTests + { + private const string Fallback = "Sorry, the order service did not answer."; + + [Fact(Timeout = 30_000)] + public async Task ToolFaultInStepTwo_SpeaksStepOneThenTheFallback_AndTheHistoryKeepsBoth() + { + TwoStepChatClient model = new(["Let me check.", TwoStepChatClient.Call], [TwoStepChatClient.Call]); + await using TelnyxRelayHost host = await TelnyxRelayHost.StartAsync( + TwoStepChatClient.YamlWithFallback(Fallback), model, TwoStepChatClient.BindToolThatFailsAfterItsFirstCall); + await using FakeRelayClient relay = await host.ConnectAsync(); + + await relay.SendAsync(RelayFrames.Setup(conversationSessionId: "voice-fault-step-two")); + await relay.SendAsync(RelayFrames.Prompt("hi", last: true)); + Assert.Equal("Let me check.", string.Concat(await relay.ReadTextFramesUntilLastAsync())); + Assert.Equal(Fallback, string.Concat(await relay.ReadTextFramesUntilLastAsync())); + + List rows = await StoredRows.SettleAsync(host.Services, "voice-fault-step-two", 4); + string all = StoredRows.Describe(rows); + Assert.True(rows[0].Content is { Text: "hi" } && rows[0].Content.Role == ChatRole.User, all); + Assert.True(rows[1].Content is { Text: "Let me check." } && rows[1].Content.Contents.OfType().Any(), all); + Assert.True(rows[2].Content.Contents.OfType().Any(), all); + Assert.True(rows[^1].Content is { Text: Fallback } && rows[^1].Content.Role == ChatRole.Assistant, all); + Assert.True(rows.Count(row => row.Content.Text.Length > 0) == 3, all); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs new file mode 100644 index 00000000..103363fe --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputBargeTests.cs @@ -0,0 +1,198 @@ +using System.Buffers; +using System.Text.Json; +using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// What reaches the relay when a barge-in races the reply's own writes. + public sealed class TelnyxRelayOutputBargeTests + { + [Fact(Timeout = 30_000)] + public async Task ABargeBetweenTheInterruptCheckAndTheWrite_SendsNothing() + { + TelnyxRelayOutput output = new(); + SpeechHandle handle = NewHandle(); + + // onFirstText runs after the forwarder's IsInterrupted check and before its SpeakAsync. + _ = await TextForwarding.ForwardAsync( + handle, + output, + Fragments("Sure, ", "your order shipped."), + onFirstText: () => Barge(output, handle), + CancellationToken.None); + + Assert.Empty(Sent(output)); + } + + [Fact(Timeout = 60_000)] + public async Task ABargeOnAnotherThread_NeverLetsTheInterruptedReplyThroughAfterTheStop() + { + int leaked = 0; + for (int run = 0; run < 2000; run++) + { + TelnyxRelayOutput output = new(); + SpeechHandle handle = NewHandle(); + using ManualResetEventSlim firstText = new(); + Task barge = Task.Run( + () => + { + firstText.Wait(); + Thread.SpinWait(Random.Shared.Next(0, 4000)); + Barge(output, handle); + }, + TestContext.Current.CancellationToken); + + Task forwarding = TextForwarding.ForwardAsync( + handle, output, YieldingFragments(40), onFirstText: firstText.Set, CancellationToken.None); + await barge; + _ = await forwarding; + + if (Sent(output).Count > 0) + { + leaked++; + } + } + + Assert.Equal(0, leaked); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeMidReply_SendsNoClosingTokenAfterTheStop() + { + TelnyxRelayOutput output = new(); + SpeechHandle handle = NewHandle(); + List beforeStop = []; + BargeBeforeWrite port = new(output, fragment => + { + if (fragment == "your order shipped.") + { + beforeStop.AddRange(Sent(output)); + Barge(output, handle); + } + }); + + _ = await TextForwarding.ForwardAsync( + handle, port, Fragments("Sure, ", "your order shipped."), onFirstText: null, CancellationToken.None); + + Assert.Equal([new RelayToken("Sure, ", Last: false)], beforeStop); + Assert.DoesNotContain(Sent(output), token => token.Last); + } + + [Fact(Timeout = 30_000)] + public async Task TheReplyAfterABarge_IsSentInFullWithItsClosingToken() + { + TelnyxRelayOutput output = new(); + SpeechHandle interrupted = NewHandle(); + _ = await TextForwarding.ForwardAsync( + interrupted, + output, + Fragments("Sure, ", "your order shipped."), + onFirstText: () => Barge(output, interrupted), + CancellationToken.None); + + _ = await TextForwarding.ForwardAsync( + NewHandle(), output, Fragments("Go ahead, ", "I'm listening."), onFirstText: null, CancellationToken.None); + + Assert.Equal( + [ + new RelayToken("Go ahead, ", Last: false), + new RelayToken("I'm listening.", Last: false), + new RelayToken(string.Empty, Last: true), + ], + Sent(output)); + } + + // A reply that never queued a fragment has nothing to close, so no closing token opens and shuts an empty reply. + [Fact(Timeout = 30_000)] + public async Task AReplyThatSpokeNothing_SendsNoClosingToken() + { + TelnyxRelayOutput output = new(); + + output.BeginReply(); + await output.CompleteAsync(TestContext.Current.CancellationToken); + + Assert.Empty(Sent(output)); + } + + private static SpeechHandle NewHandle() + { + return SpeechHandle.Create(TimeProvider.System, NullLogger.Instance); + } + + private static void Barge(TelnyxRelayOutput output, SpeechHandle handle) + { + output.StopAsync().AsTask().Wait(); + _ = handle.Interrupt(source: InterruptionSource.AudioActivity); + } + + private static async IAsyncEnumerable Fragments(params string[] fragments) + { + foreach (string fragment in fragments) + { + await Task.Yield(); + yield return fragment; + } + } + + private static async IAsyncEnumerable YieldingFragments(int count) + { + for (int n = 0; n < count; n++) + { + await Task.Yield(); + yield return "w" + n + " "; + } + } + + /// Reads the queue as the write loop does, and returns each token it would send. + private static List Sent(TelnyxRelayOutput output) + { + List sent = []; + ArrayBufferWriter buffer = new(); + using Utf8JsonWriter writer = new(buffer); + while (output.Reader.TryRead(out OutboundItem item)) + { + buffer.Clear(); + writer.Reset(buffer); + if (output.Encode(item, writer)) + { + sent.Add((RelayToken)item.Frame); + } + } + + return sent; + } + + /// Runs a hook between the forwarder's interrupt check and the relay's own write. + private sealed class BargeBeforeWrite(TelnyxRelayOutput inner, Action beforeWrite) : IConversationOutputPort + { + public void BeginReply() + { + inner.BeginReply(); + } + + public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default) + { + beforeWrite(fragment); + return inner.SpeakAsync(fragment, cancellationToken); + } + + public ValueTask CompleteAsync(CancellationToken cancellationToken = default) + { + return inner.CompleteAsync(cancellationToken); + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + return inner.StopAsync(cancellationToken); + } + + public ValueTask DisposeAsync() + { + return inner.DisposeAsync(); + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs new file mode 100644 index 00000000..2b324e2d --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/TelnyxRelay/TelnyxRelayOutputQueueTests.cs @@ -0,0 +1,75 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay +{ + /// Where the relay's outbound queue meets the write loop: a stop racing a send, and a queue ended under a write. + public sealed class TelnyxRelayOutputQueueTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // IConversationOutputPort.BeginReply: every write of a stopped reply is dropped. A frame the write loop already + // holds when the stop lands may still go out, but only before the stop returns, never after it. + [Fact(Timeout = 30_000)] + public async Task AStopWhileTheWriteLoopHoldsAFrame_ReturnsOnlyOnceThatFrameIsSent() + { + TelnyxRelayOutput output = new(); + FakeWebSocket socket = new(); + JsonWebSocketSender sender = new(socket, TimeSpan.FromSeconds(5), CancellationToken.None); + output.BeginReply(); + await output.SpeakAsync("Hello ", Ct); + output.Complete(); + + Thread? stopper = null; + int sentWhenStopReturned = -1; + await sender.WriteLoopAsync( + output.Reader, + (item, writer) => + { + bool encoded = output.Encode(item, writer); + stopper = new Thread(() => + { + output.StopAsync(Ct).AsTask().Wait(Ct); + sentWhenStopReturned = socket.Sent.Count; + }); + stopper.Start(); + + // The frame stays in hand until the stop has either returned or blocked: a blocked thread shows + // WaitSleepJoin, a returned one Stopped, and one still on its way shows neither. + while ((stopper.ThreadState & (ThreadState.WaitSleepJoin | ThreadState.Stopped)) == 0) + { + _ = Thread.Yield(); + } + + return encoded; + }, + output.SendGate); + stopper!.Join(); + + _ = Assert.Single(socket.Sent); + Assert.Equal(1, sentWhenStopReturned); + } + + // A write loop that is gone never frees a writer waiting on the full queue, so ending the queue must. + [Fact(Timeout = 30_000)] + public async Task AWriteWaitingOnAFullQueue_ReturnsWithoutAFaultOnceTheQueueEnds() + { + TelnyxRelayOutput output = new(); + output.BeginReply(); + for (int word = 0; word < 256; word++) + { + await output.SpeakAsync("w" + word + " ", Ct); + } + + ValueTask waiting = output.SpeakAsync("one too many", Ct); + Assert.False(waiting.IsCompleted); + + output.Complete(); + + await waiting; + await output.CompleteAsync(Ct); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs new file mode 100644 index 00000000..a1d1a781 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/AsyncEventTests.cs @@ -0,0 +1,85 @@ +using AgentCore.AspNetCore.Voice; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// Set/clear/wait races on . + public sealed class AsyncEventTests + { + [Fact(Timeout = 30_000)] + public async Task AWaiter_BlocksUntilSetIsCalled() + { + AsyncEvent ev = new(); + Task waiting = ev.WaitAsync(TestContext.Current.CancellationToken); + + Assert.False(waiting.IsCompleted); + + ev.Set(); + + await waiting; + Assert.True(ev.IsSet); + } + + [Fact(Timeout = 30_000)] + public async Task Clear_ClosesTheGateAgainForTheNextRound() + { + AsyncEvent ev = new(); + ev.Set(); + ev.Clear(); + + Assert.False(ev.IsSet); + + Task waiting = ev.WaitAsync(TestContext.Current.CancellationToken); + Assert.False(waiting.IsCompleted); + + ev.Set(); + await waiting; + } + + [Fact(Timeout = 30_000)] + public async Task AWaiterAlreadyReleasedBySet_IsUnaffectedByALaterClear() + { + AsyncEvent ev = new(); + Task released = ev.WaitAsync(TestContext.Current.CancellationToken); + + ev.Set(); + ev.Clear(); + + await released; + Assert.True(released.IsCompletedSuccessfully); + } + + // asyncio.Event.clear() on an event that is not set changes nothing for its waiters. + [Fact] + public void ClearOnAnEventThatIsNotSet_KeepsTheWaiterForTheNextSet() + { + AsyncEvent ev = new(); + + // No token, so the waiter is the event's own task and completes inside Set. + Task waiting = ev.WaitAsync(CancellationToken.None); + + ev.Clear(); + ev.Set(); + + Assert.True(waiting.IsCompletedSuccessfully); + } + + [Fact(Timeout = 30_000)] + public async Task CancellingAWait_DoesNotSetTheEventForOtherWaiters() + { + AsyncEvent ev = new(); + using CancellationTokenSource cts = new(); + Task cancelled = ev.WaitAsync(cts.Token); + Task other = ev.WaitAsync(TestContext.Current.CancellationToken); + + await cts.CancelAsync(); + await Assert.ThrowsAsync(() => cancelled); + + Assert.False(ev.IsSet); + Assert.False(other.IsCompleted); + + ev.Set(); + await other; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs new file mode 100644 index 00000000..f503413e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelShapeTests.cs @@ -0,0 +1,35 @@ +using AgentCore.AspNetCore.Voice; +using AgentCore.AspNetCore.Tests.Fakes; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// + /// The voice loop serves a split channel exactly as it serves a bundled one. + /// + public sealed class ConversationChannelShapeTests + { + [Fact(Timeout = 30_000)] + public async Task ASplitChannelOfTwoObjects_RunsAConversationThroughTheVoiceLoop() + { + using FragmentingChatClient reply = new("hello there"); + FakeConversationOutput output = new(); + FakeConversationInput input = new( + new ConversationInput.Started("conversation-shape"), + new ConversationInput.Utterance("hello", "en", IsFinal: true)); + ConversationChannel channel = new(input, output); + VoiceLoopHarness harness = VoiceLoopHarness.Create(reply, channel.Output); + + await harness.Loop.RunAsync(channel.Input.ListenAsync(TestContext.Current.CancellationToken)); + await Poll.UntilAsync(() => output.Completions == 1); + + Assert.NotEmpty(output.Spoken); + Assert.Equal(1, output.Completions); + + await harness.Loop.DrainAsync(); + await channel.DisposeAsync(); + Assert.Equal(1, input.Disposals); + Assert.Equal(1, output.Disposals); + } + } +} diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationChannelTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs similarity index 92% rename from tests/AgentCore.Application.Tests/Conversation/ConversationChannelTests.cs rename to tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs index 1a3dd4e4..6a3643f3 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ConversationChannelTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationChannelTests.cs @@ -1,8 +1,7 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Conversation; +using AgentCore.AspNetCore.Voice; using Xunit; -namespace AgentCore.Application.Tests.Conversation +namespace AgentCore.AspNetCore.Tests.Voice { /// /// The channel pair, including the case where one object fills both slots. @@ -55,6 +54,11 @@ public IAsyncEnumerable ListenAsync(CancellationToken cancell throw new NotSupportedException(); } + public void BeginReply() + { + throw new NotSupportedException(); + } + public ValueTask SpeakAsync(string fragment, CancellationToken cancellationToken = default) { throw new NotSupportedException(); diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationInputPortTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs similarity index 81% rename from tests/AgentCore.AspNetCore.Tests/Conversation/ConversationInputPortTests.cs rename to tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs index af7dad14..4b65464a 100644 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationInputPortTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationInputPortTests.cs @@ -1,21 +1,13 @@ -using AgentCore.Application.Ports; -using AgentCore.Application.Conversation; using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; using Xunit; -namespace AgentCore.AspNetCore.Tests.Conversation +namespace AgentCore.AspNetCore.Tests.Voice { /// /// The two promises makes that a signature cannot: one consumer for /// the life of the port, and a cancelled read that throws rather than passing for the end of a conversation. /// - /// - /// Pinned on the test double, which is the only implementation reachable without a live socket. The - /// relay connection's own guard is the same three lines over the same - /// flag, but its constructor is private and RunAsync — the one way in — needs an accepted - /// WebSocket and a request's service provider, so nothing here can hold one. What keeps the two - /// honest is that both quote the same rule from the port's own doc. - /// public sealed class ConversationInputPortTests { [Fact(Timeout = 30_000)] diff --git a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRouteSelectionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs similarity index 83% rename from tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRouteSelectionTests.cs rename to tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs index af4aeb2a..a1f22bd1 100644 --- a/tests/AgentCore.AspNetCore.Tests/Conversation/ConversationRouteSelectionTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationRouteSelectionTests.cs @@ -1,7 +1,6 @@ using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Ports; -using AgentCore.AspNetCore.Conversation; +using AgentCore.AspNetCore.Voice; using AgentCore.AspNetCore.DependencyInjection; using AgentCore.AspNetCore.DependencyInjection.Startup; using Microsoft.AspNetCore.Builder; @@ -13,29 +12,12 @@ using Microsoft.Extensions.Logging; using Xunit; -namespace AgentCore.AspNetCore.Tests.Conversation +namespace AgentCore.AspNetCore.Tests.Voice { /// /// providers.conversation decides which transport answers the one conversation route, and whether a conversation /// routes here at all. /// - /// - /// - /// No fake here names Telnyx, and this file references no vendor type. That is the point of - /// the seam — spec §12. If this file ever needs a vendor type to prove the route, the selection has - /// leaked back into the vendor. - /// - /// - /// The selection happens while the host starts and not while the route is mapped, so these read - /// rather than MapCall. A host maps its routes on a built - /// application, which is after the document has been read; deciding there would mean a document - /// edit could silently leave the path a 404. - /// - /// - /// Every test runs offline. There is no account, no network conversation, and no API key anywhere in this - /// file. - /// - /// public sealed class ConversationRouteSelectionTests { [Fact] @@ -163,12 +145,6 @@ private static string Address(WebApplication app) /// Writes one document that names both blocks section 8.2 requires of one another. /// The value providers.conversation.kind carries. /// The document text. - /// - /// Written line by line here rather than at each call site, because the block sits under - /// providers: and YAML indentation written by hand at a conversation site is how test documents - /// go wrong. speech names the same kind as conversation because the schema requires the - /// block to exist; nothing in this file selects a speech vendor. - /// private static string Document(string conversationKind) { return $$""" @@ -190,11 +166,6 @@ private static string Document(string conversationKind) } /// A transport that answers a conversation and names no vendor. - /// - /// It records the block the seam is supposed to hand it. Recording only that - /// CreateHandler ran would let a seam that resolved the document block to something other - /// than the one the reader wrote still pass. - /// private sealed class FakeTransport(string kind) : IConversationTransportAdapter { public string Kind { get; } = kind; diff --git a/tests/AgentCore.Application.Tests/Conversation/ConversationSpeechPairingTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs similarity index 97% rename from tests/AgentCore.Application.Tests/Conversation/ConversationSpeechPairingTests.cs rename to tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs index 24dad2a2..9aa80f24 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ConversationSpeechPairingTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationSpeechPairingTests.cs @@ -1,10 +1,9 @@ -using AgentCore.Application.Conversation; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; -using AgentCore.Application.Ports; +using AgentCore.AspNetCore.Voice; using Xunit; -namespace AgentCore.Application.Tests.Conversation +namespace AgentCore.AspNetCore.Tests.Voice { public sealed class ConversationSpeechPairingTests { diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs new file mode 100644 index 00000000..fe14b612 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ConversationTurnLatencySuite.cs @@ -0,0 +1,14 @@ +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// + /// The collection the latency tests run in, alone. + /// + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class ConversationTurnLatencySuite + { + /// The name both the definition and the test class name it by. + public const string Name = "conversation turn latency"; + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs new file mode 100644 index 00000000..447688a7 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/FillerSchedulerTests.cs @@ -0,0 +1,292 @@ +// Portions derived from LiveKit Agents, tests/test_filler.py (test_scheduler_can_start_and_stop_without_firing +// :118, test_scheduler_stop_is_idempotent :138, test_scheduler_fires_after_idle_dwell :156, +// test_scheduler_does_not_fire_before_dwell_elapses :175, test_scheduler_dwell_resets_on_agent_state_change +// :194, test_scheduler_dwell_resets_on_user_speaking :222, test_scheduler_invokes_callable_source_lazily +// :245, test_scheduler_fires_repeatedly_with_interval :274, test_scheduler_interval_none_fires_once_only +// :295, test_scheduler_does_not_fire_after_speech_interrupted :357, test_scheduler_reports_a_say_that_raises +// :447), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#, over a real VoiceSession and +// a FakeTimeProvider rather than a hand-rolled fake session and real wall-clock sleeps. LiveKit's own +// suite runs these under an autojumping virtual clock (tests/virtual_time.py) that advances only once the +// loop is otherwise idle. Here each test first waits until the loop has armed the timer it is about to +// pass (FakeTimeProvider.WaitForTimersAsync), then advances the clock. + +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// 's idle dwell, its reset on a state change, its interval and + /// step cap, and how it stops when the speech it serves is interrupted or it faults. + public sealed class FillerSchedulerTests : IAsyncDisposable + { + private const int FillerFaulted = 32; + + private static readonly DateTimeOffset Start = DateTimeOffset.UnixEpoch; + + private readonly FakeTimeProvider _time = new(Start); + + private readonly FakeConversationOutput _output = new(); + + private readonly VoiceSession _session; + + private readonly SpeechHandle _handle; + + public FillerSchedulerTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + _handle = SpeechHandle.Create(_time, NullLogger.Instance); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task AScheduler_StoppedBeforeTheDwellElapses_FiresNothing() + { + FillerScheduler scheduler = new(_session, _handle, "should not fire", TimeSpan.FromSeconds(10)); + await ArmedAsync(Start.AddSeconds(10)); + + await scheduler.CloseAsync(); + + Assert.True(_time.WaitForTimersAsync(Start.AddSeconds(10), 0).IsCompleted); + Assert.Empty(_output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task Close_IsIdempotent() + { + FillerScheduler scheduler = new(_session, _handle, "x", TimeSpan.FromSeconds(10)); + + await scheduler.CloseAsync(); + await scheduler.CloseAsync(); + } + + // filler_scheduler.py (_FillerScheduler.__init__): a negative dwell or interval is refused. + [Fact] + public void ANegativeDelayOrInterval_IsRefused() + { + _ = Assert.Throws( + () => new FillerScheduler(_session, _handle, "x", TimeSpan.FromSeconds(-1))); + _ = Assert.Throws( + () => new FillerScheduler(_session, _handle, "x", TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(-1))); + } + + [Fact(Timeout = 30_000)] + public async Task ItFires_OnceTheSessionHasBeenIdleForTheDwell() + { + FillerScheduler scheduler = new(_session, _handle, "let me check", TimeSpan.FromMilliseconds(50)); + await ArmedAsync(Start.AddMilliseconds(50)); + + _time.Advance(TimeSpan.FromMilliseconds(50)); + await _output.WaitForLogAsync(2); + await scheduler.CloseAsync(); + + Assert.Equal(["let me check"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ItNeverFires_BeforeTheDwellElapses() + { + FillerScheduler scheduler = new(_session, _handle, "let me check", TimeSpan.FromSeconds(1)); + await ArmedAsync(Start.AddSeconds(1)); + + _time.Advance(TimeSpan.FromMilliseconds(50)); + await scheduler.CloseAsync(); + + Assert.Empty(_output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task AnAgentStateChangeMidDwell_RestartsIt() + { + FillerScheduler scheduler = new(_session, _handle, "ping", TimeSpan.FromMilliseconds(100)); + await ArmedAsync(Start.AddMilliseconds(100)); + _time.Advance(TimeSpan.FromMilliseconds(50)); + + _session.SetAgentState(AgentState.Speaking); + await ArmedAsync(Start.AddMilliseconds(150)); + + _time.Advance(TimeSpan.FromMilliseconds(80)); + Assert.Empty(_output.Spoken); + + _time.Advance(TimeSpan.FromMilliseconds(20)); + await _output.WaitForLogAsync(2); + await scheduler.CloseAsync(); + + Assert.Equal(["ping"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task AnAgentThinkingMidDwell_RestartsIt() + { + FillerScheduler scheduler = new(_session, _handle, "ping", TimeSpan.FromMilliseconds(100)); + await ArmedAsync(Start.AddMilliseconds(100)); + _time.Advance(TimeSpan.FromMilliseconds(50)); + + _session.SetAgentState(AgentState.Thinking); + await ArmedAsync(Start.AddMilliseconds(150)); + await scheduler.CloseAsync(); + + Assert.Empty(_output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task UserStateSpeakingMidDwell_RestartsIt() + { + FillerScheduler scheduler = new(_session, _handle, "ping", TimeSpan.FromMilliseconds(100)); + await ArmedAsync(Start.AddMilliseconds(100)); + _time.Advance(TimeSpan.FromMilliseconds(50)); + + _session.SetUserState(UserState.Speaking); + await ArmedAsync(Start.AddMilliseconds(150)); + + _time.Advance(TimeSpan.FromMilliseconds(80)); + Assert.Empty(_output.Spoken); + + _time.Advance(TimeSpan.FromMilliseconds(20)); + await _output.WaitForLogAsync(2); + await scheduler.CloseAsync(); + + Assert.Equal(["ping"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ACallableSource_IsInvokedLazilyAtFireTime() + { + List invocations = []; + FillerSource source = FillerSource.FromCallable(step => + { + invocations.Add(step); + return _session.Say("from callable"); + }); + FillerScheduler scheduler = new(_session, _handle, source, TimeSpan.FromMilliseconds(50)); + await ArmedAsync(Start.AddMilliseconds(50)); + + Assert.Empty(invocations); + _time.Advance(TimeSpan.FromMilliseconds(50)); + await _output.WaitForLogAsync(2); + await scheduler.CloseAsync(); + + Assert.Equal([0], invocations); + Assert.Equal(["from callable"], _output.Spoken); + } + + // filler_scheduler.py:84-90: a callable that returns nothing skips its fire. + [Fact(Timeout = 30_000)] + public async Task ACallableSourceReturningNothing_SkipsThatFire() + { + FillerScheduler scheduler = new( + _session, _handle, FillerSource.FromCallable(static _ => null), TimeSpan.FromMilliseconds(50)); + await ArmedAsync(Start.AddMilliseconds(50)); + + _time.Advance(TimeSpan.FromMilliseconds(50)); + await scheduler.CloseAsync(); + + Assert.Empty(scheduler.CreatedSpeeches); + } + + [Fact(Timeout = 30_000)] + public async Task AnIntervalSet_FiresAgainAfterEachCooldown() + { + FillerScheduler scheduler = new( + _session, _handle, "tick", TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50)); + + for (int fire = 1; fire <= 3; fire++) + { + DateTimeOffset dwellEnds = Start.AddMilliseconds(100 * fire - 50); + await ArmedAsync(dwellEnds); + _time.Advance(TimeSpan.FromMilliseconds(50)); + await _output.WaitForLogAsync(2 * fire); + + await ArmedAsync(dwellEnds.AddMilliseconds(50)); + Assert.Equal(fire, _output.Spoken.Count); + _time.Advance(TimeSpan.FromMilliseconds(50)); + } + + await scheduler.CloseAsync(); + + Assert.Equal(["tick", "tick", "tick"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task AStepCap_StopsFiringOnceReached() + { + FillerScheduler scheduler = new( + _session, _handle, "tick", TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(50), maxSteps: 1); + await ArmedAsync(Start.AddMilliseconds(50)); + + _time.Advance(TimeSpan.FromMilliseconds(50)); + await _output.WaitForLogAsync(2); + + Assert.True(_time.WaitForTimersAsync(Start.AddMilliseconds(100), 0).IsCompleted, "a cooldown was armed past the cap."); + await scheduler.CloseAsync(); + Assert.Equal(["tick"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task NoInterval_FiresAtMostOnce() + { + FillerScheduler scheduler = new(_session, _handle, "once", TimeSpan.FromMilliseconds(20)); + await ArmedAsync(Start.AddMilliseconds(20)); + + _time.Advance(TimeSpan.FromMilliseconds(20)); + await _output.WaitForLogAsync(2); + _time.Advance(TimeSpan.FromSeconds(1)); + await scheduler.CloseAsync(); + + Assert.Equal(["once"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task TheSpeechBeingInterrupted_StopsFurtherFiring() + { + FillerScheduler scheduler = new( + _session, _handle, "x", TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(20)); + await ArmedAsync(Start.AddMilliseconds(20)); + _time.Advance(TimeSpan.FromMilliseconds(20)); + await _output.WaitForLogAsync(2); + await ArmedAsync(Start.AddMilliseconds(40)); + + _ = _handle.Interrupt(); + + await _time.WaitForTimersAsync(Start.AddMilliseconds(40), 0); + _time.Advance(TimeSpan.FromMilliseconds(200)); + await scheduler.CloseAsync(); + Assert.Equal(["x"], _output.Spoken); + } + + // A source that raises is reported: nothing else would tell the caller the filler died on its first fire. + [Fact(Timeout = 30_000)] + public async Task ASourceThatRaises_IsLoggedAndStopsTheLoop() + { + RecordingLoggerFactory logs = new(); + VoiceSession loggedSession = new(_output, _time, logs.CreateLogger("voice")); + FillerSource source = FillerSource.FromCallable(static _ => + throw new InvalidOperationException("no TTS model and the RealtimeSession does not support say()")); + + FillerScheduler scheduler = new(loggedSession, _handle, source, TimeSpan.FromMilliseconds(20), TimeSpan.FromSeconds(10), 3); + await ArmedAsync(Start.AddMilliseconds(20)); + _time.Advance(TimeSpan.FromMilliseconds(20)); + await Poll.UntilAsync(() => logs.Of(FillerFaulted).Count >= 1); + await scheduler.CloseAsync(); + + CapturedLine line = Assert.Single(logs.Of(FillerFaulted)); + Assert.Equal(LogLevel.Error, line.Level); + Assert.IsType(line.Exception); + Assert.Contains("does not support say()", line.Exception!.Message, StringComparison.Ordinal); + } + + /// Waits until the loop has armed the one timer due at . + private Task ArmedAsync(DateTimeOffset dueAt) + { + return _time.WaitForTimersAsync(dueAt, 1); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs new file mode 100644 index 00000000..e2fce64b --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/FinalTranscriptInterruptTests.cs @@ -0,0 +1,127 @@ +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// What a final prompt does to the speech the caller talked over, in LiveKit's order. + public sealed class FinalTranscriptInterruptTests : IAsyncDisposable + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceSession _session; + + private readonly VoiceActivity _activity; + + private readonly UserTurnHandler _handler; + + public FinalTranscriptInterruptTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + _activity = new VoiceActivity(_session, _port, CancellationToken.None); + _handler = new UserTurnHandler(_session, _activity); + } + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // agent_activity.py:2552 before 2433: on_final_transcript interrupts the current speech before on_end_of_speech + // sets the silence a held step waits on, so the step the caller's speech held never goes out. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscript_InterruptsAStepTheCallersSpeechHeldBeforeItCanSpeak() + { + SpeechHandle reply = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me check."); + await turn.CallAsync("1", "lookup_order"); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches && _session.Scheduler.CurrentSpeech is null); + + _handler.OnInterimTranscript(); + await turn.ResultAsync("1"); + await turn.TextAsync("Your order ships today."); + turn.End(); + await Poll.UntilAsync(() => _session.Scheduler.CurrentSpeech == reply); + + // The user turn waits behind an earlier one, so only the final prompt's own interrupt can stop the step. + TaskCompletionSource earlier = new(TaskCreationOptions.RunContinuationsAsynchronously); + _session.Scheduler.SetUserTurnTask(earlier.Task); + _handler.OnFinalTranscript("Never mind."); + _ = await reply; + + Assert.True(reply.IsInterrupted); + Assert.Equal(["Let me check."], _output.Spoken); + Assert.Equal([(0, new TurnCut("Let me check.", null))], _port.Cuts); + + _ = earlier.TrySetResult(); + Assert.Equal("Never mind.", await _port.Turn(2).Started); + _port.Turn(2).End(); + await _session.WaitForIdleAsync(Ct); + } + + // generation.py:748-768 and the owner's ruling that a cut keeps exactly the heard text: a final prompt cuts a + // step mid-way, which stops the turn at once with the text forwarded; the reply then waits for the + // transport's report of what was heard, recuts its turn to it, and only then does the next reply's turn start. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptMidStep_CutsTheTurnAtOnce_ThenRecutsItToTheHeardTextThatFollows() + { + SpeechHandle reply = _activity.GenerateReply("hi"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Hello there, "); + await _output.WaitForLogAsync(1); + + _handler.OnFinalTranscript("wait"); + _ = await Task.WhenAny( + reply.WaitForPlayoutAsync(Ct), + _time.WaitForTimersAsync(_time.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1)); + Assert.Equal([(0, new TurnCut("Hello there, ", null))], _port.Cuts); + Assert.False(_port.Turn(2).Started.IsCompleted); + + await _activity.InterruptByAudioActivity("Hello", TimeSpan.FromMilliseconds(400)); + _ = await reply; + + Assert.Equal([(0, new TurnCut("Hello", TimeSpan.FromMilliseconds(400)))], _port.Recuts); + Assert.Equal("wait", await _port.Turn(2).Started); + _port.Turn(2).End(); + await _session.WaitForIdleAsync(Ct); + } + + // agent_activity.py:2788-2796: scheduling paused by a close while the user turn waited for the speech it + // interrupted, so the turn is skipped: no engine turn starts, and the user-turn task ends without a fault. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptWhoseSpeechEndsAfterAClosePausedScheduling_StartsNoTurnAndFaultsNothing() + { + RecordingLoggerFactory logs = new(); + VoiceSession session = new(_output, _time, logs.CreateLogger("voice")); + VoiceActivity activity = new(session, _port, CancellationToken.None); + UserTurnHandler handler = new(session, activity); + SpeechHandle reply = activity.GenerateReply("hi"); + await _port.Turn(1).TextAsync("Hello there, "); + await _output.WaitForLogAsync(1); + + handler.OnFinalTranscript("wait"); + Task turn = session.Scheduler.UserTurnTask!; + await _time.WaitForTimersAsync(_time.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1); + Task pausing = session.Scheduler.PauseSchedulingAsync(); + _time.Advance(VoiceOptions.DefaultHeardTextWait); + + await turn; + await pausing; + + Assert.True(reply.IsDone); + _ = Assert.Single(logs.Of(31)); + Assert.Empty(logs.Of(28)); + Assert.False(_port.Turn(2).Started.IsCompleted); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs new file mode 100644 index 00000000..956e07d3 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyEngineTurnTests.cs @@ -0,0 +1,231 @@ +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// How a runs, cuts and ends the engine turn behind it (). + public sealed class PipelineReplyEngineTurnTests : IAsyncDisposable + { + private const int SpeechTaskFaulted = 28; + + private const int CutRefused = 34; + + private const int RecutRefused = 35; + + private static readonly DateTimeOffset Start = DateTimeOffset.UnixEpoch; + + private readonly FakeTimeProvider _time = new(Start); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceSession _session; + + private readonly VoiceActivity _activity; + + public PipelineReplyEngineTurnTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + _activity = new VoiceActivity(_session, _port, CancellationToken.None); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // VoiceActivity (plan G8): engine turns run one at a time, each once the previous one has ended. + [Fact(Timeout = 30_000)] + public async Task ASecondReply_StartsItsEngineTurnOnlyOnceTheFirstHasEnded() + { + SpeechHandle first = _activity.GenerateReply("First question"); + SpeechHandle second = _activity.GenerateReply("Second question"); + + Assert.Equal("First question", await _port.Turn(1).Started); + await _port.Turn(1).TextAsync("The first answer."); + _port.Turn(1).End(); + _ = await first; + + Task started = _port.Turn(2).Started; + Assert.Same(started, await Task.WhenAny(started, second.WaitForPlayoutAsync(TestContext.Current.CancellationToken))); + Assert.Equal("Second question", await started); + await _port.Turn(2).TextAsync("The second answer."); + _port.Turn(2).End(); + _ = await second; + + Assert.Null(second.Error); + Assert.Equal(["The first answer.", "The second answer."], _output.Spoken); + } + + // EngineReplyStream.Cut: a turn still waiting on the one before it takes the cut the moment it starts, so the + // caller's words are kept (design section 3). + [Fact(Timeout = 30_000)] + public async Task AReplyInterruptedBeforeItsTurnStarted_CutsThatTurnTheMomentItStarts() + { + SpeechHandle first = _activity.GenerateReply("First question"); + SpeechHandle second = _activity.GenerateReply("Second question"); + await _port.Turn(1).TextAsync("The first answer."); + + _ = second.Interrupt(source: InterruptionSource.UserTurn); + _ = await second; + _port.Turn(1).End(); + _ = await first; + + Assert.Equal("Second question", await _port.Turn(2).Started); + Assert.Equal([(1, new TurnCut(string.Empty, null))], _port.Cuts); + await _port.Turn(2).Finished; + } + + // EngineReplyStream: a turn its owner cancels is not a fault (LiveKit's _on_llm_task_done skips a cancelled task). + [Fact(Timeout = 30_000)] + public async Task AnEngineTurnItsOwnerCancels_EndsTheSpeechWithoutAnError() + { + using CancellationTokenSource transport = new(); + VoiceActivity activity = new(_session, _port, transport.Token); + SpeechHandle handle = activity.GenerateReply("Hello?"); + _ = await _port.Turn(1).Started; + + await transport.CancelAsync(); + _ = await handle; + + Assert.Null(handle.Error); + } + + // agent_activity.py:3866-3874: a running tool keeps the agent "thinking"; "listening" would arm the away timer. + [Fact(Timeout = 30_000)] + public async Task AStepEndingOnAToolCall_LeavesTheAgentThinkingWhileTheToolRuns() + { + SpeechHandle handle = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look that up."); + await turn.CallAsync("1", "lookup_order"); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches); + + Assert.Equal(AgentState.Thinking, _session.AgentState); + + await turn.ResultAsync("1"); + turn.End(); + _ = await handle; + } + + // PipelineReply (design section 3): an interruption with no barge-in behind it keeps the text forwarded. + [Fact(Timeout = 30_000)] + public async Task AUserTurnInterruptingAReplyWhileItsToolRuns_CutsTheTurnToTheForwardedText() + { + SpeechHandle handle = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look that up."); + await turn.CallAsync("1", "lookup_order"); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches); + + _ = handle.Interrupt(source: InterruptionSource.UserTurn); + _ = await handle; + + Assert.Equal([(0, new TurnCut("Let me look that up.", null))], _port.Cuts); + Assert.True(turn.Finished.IsCompleted); + } + + // PipelineReply.ForwardStepAsync: LiveKit opens a segment on the first non-empty text only, so a step with no + // words never closes a reply on the transport. + [Fact(Timeout = 30_000)] + public async Task AStepWithNoWords_ClosesNoReply() + { + SpeechHandle handle = _activity.GenerateReply("Check my order."); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync(string.Empty); + await turn.CallAsync("1", "lookup_order"); + await turn.ResultAsync("1"); + await turn.TextAsync("It shipped."); + turn.End(); + _ = await handle; + + Assert.Equal(["speak:It shipped.", "complete"], _output.Log); + } + + // EngineReplyStream.WaitForRoundAsync reads the round of this turn's own calls; a result for a call it never + // made does not end the step. + [Fact(Timeout = 30_000)] + public async Task AResultForACallThisTurnNeverMade_LeavesTheStepWhole() + { + SpeechHandle handle = _activity.GenerateReply("Any news?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Yes."); + await turn.ResultAsync("an-earlier-call"); + await turn.TextAsync(" It shipped."); + turn.End(); + _ = await handle; + + Assert.Equal(["speak:Yes.", "speak: It shipped.", "complete"], _output.Log); + } + + // PipelineReply.EndReply: a genuine engine failure surfaces through the speech's error (LiveKit's _on_llm_task_done). + [Fact(Timeout = 30_000)] + public async Task AnEngineTurnThatFails_EndsTheSpeechWithItsError() + { + _ = await _port.StartTurnAsync( + new ChatMessage(ChatRole.User, "a turn still running"), origin: null, TestContext.Current.CancellationToken); + + SpeechHandle handle = _activity.GenerateReply("Hello?"); + _ = await handle; + + _ = Assert.IsType(handle.Error); + Assert.Empty(_output.Spoken); + } + + // PipelineReply.EndInterruptedAsync: once the backstop cancels the speech's token, the speech ends without a fault. + [Fact(Timeout = 30_000)] + public async Task AnInterruptedReplyWhoseTurnOutlivesTheBackstop_EndsWithoutAFault() + { + RecordingLoggerFactory logs = new(); + VoiceSession session = new(_output, _time, logs.CreateLogger("voice")); + VoiceActivity activity = new(session, _port, CancellationToken.None); + _port.EndTurnOnCut = false; + SpeechHandle handle = activity.GenerateReply("Tell me a story."); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Here is a long story"); + await _output.WaitForLogAsync(1); + + _ = handle.Interrupt(source: InterruptionSource.UserTurn); + await _time.WaitForTimersAsync(Start + SpeechHandle.InterruptionTimeout, 1); + _time.Advance(SpeechHandle.InterruptionTimeout); + _ = await handle; + await session.Scheduler.PauseSchedulingAsync(); + + Assert.Empty(logs.Of(SpeechTaskFaulted)); + turn.End(); + await turn.Finished; + } + + // IConversationPort.Recut refuses a turn older than the newest one started. The turn did take its cut, so + // the refusal is logged as a refused recut, never as a cut refused because the turn was already cut. + [Fact(Timeout = 30_000)] + public async Task ARecutRefusedAfterALaterTurnStarted_IsLoggedAsARefusedRecut() + { + RecordingLoggerFactory logs = new(); + EngineReplyStream stream = EngineReplyStream.Start( + _port, + "First question", + Task.CompletedTask, + SpeechHandle.Create(_time, NullLogger.Instance), + new TurnMetrics(_time, userTurnEndedAt: null), + logs.CreateLogger("voice"), + CancellationToken.None); + _ = await _port.Turn(1).Started; + Assert.True(stream.Cut(new TurnCut("The first", null))); + await stream.Completion; + await using TurnRun later = await _port.StartTurnAsync( + new ChatMessage(ChatRole.User, "Second question"), origin: null, TestContext.Current.CancellationToken); + + stream.Recut(new TurnCut("The first answer", null)); + + Assert.Empty(logs.Of(CutRefused)); + Assert.Equal(0, Assert.Single(logs.Of(RecutRefused)).Field("TurnIndex")); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs new file mode 100644 index 00000000..bfcf69e0 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyFillerTests.cs @@ -0,0 +1,137 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// The filler each tool call opens in a parallel tool round of . + public sealed class PipelineReplyFillerTests : IAsyncDisposable + { + private static readonly DateTimeOffset Start = DateTimeOffset.UnixEpoch; + + private readonly FakeTimeProvider _time = new(Start); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceSession _session; + + public PipelineReplyFillerTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // Final review probe D (2026-09-23). + [Fact(Timeout = 30_000)] + public async Task AFastToolsFiller_StopsWhenItsOwnToolReturns_WhileASlowToolStillRuns() + { + VoiceActivity activity = Activity(new() { ["fast"] = new("still on the fast one", TimeSpan.FromSeconds(2)) }); + + SpeechHandle handle = activity.GenerateReply("Where are my orders?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look."); + await turn.CallAsync("c1", "fast"); + await turn.CallAsync("c2", "slow"); + await _time.WaitForTimersAsync(Start.AddSeconds(2), 1); + + await turn.ResultAsync("c1"); + await _time.WaitForTimersAsync(Start.AddSeconds(2), 0); + _time.Advance(TimeSpan.FromSeconds(2)); + + await turn.ResultAsync("c2"); + await turn.TextAsync("Both shipped."); + turn.End(); + _ = await handle; + + Assert.Equal(["Let me look.", "Both shipped."], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ASlowToolsFiller_StillPlays_AfterAFastToolReturns() + { + VoiceActivity activity = Activity(new() + { + ["fast"] = new("still on the fast one", TimeSpan.FromSeconds(2)), + ["slow"] = new("still on the slow one", TimeSpan.FromSeconds(3)), + }); + + SpeechHandle handle = activity.GenerateReply("Where are my orders?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look."); + await turn.CallAsync("c1", "fast"); + await turn.CallAsync("c2", "slow"); + await _time.WaitForTimersAsync(Start.AddSeconds(2), 1); + await _time.WaitForTimersAsync(Start.AddSeconds(3), 1); + + await turn.ResultAsync("c1"); + await _time.WaitForTimersAsync(Start.AddSeconds(2), 0); + _time.Advance(TimeSpan.FromSeconds(3)); + await _output.WaitForLogAsync(4); + + await turn.ResultAsync("c2"); + await turn.TextAsync("Both shipped."); + turn.End(); + _ = await handle; + + Assert.Equal(["Let me look.", "still on the slow one", "Both shipped."], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeInDuringTheRound_ClosesEveryScopeBeforeTheSpeechEnds() + { + VoiceActivity activity = Activity(new() + { + ["fast"] = new("still on the fast one", TimeSpan.FromSeconds(2)), + ["slow"] = new("still on the slow one", TimeSpan.FromSeconds(2)), + ["other"] = new("still on the other one", TimeSpan.FromSeconds(2)), + }); + + SpeechHandle handle = activity.GenerateReply("Where are my orders?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look."); + await turn.CallAsync("c1", "fast"); + await turn.CallAsync("c2", "slow"); + await turn.CallAsync("c3", "other"); + await _time.WaitForTimersAsync(Start.AddSeconds(2), 3); + await turn.ResultAsync("c1"); + + await activity.InterruptByAudioActivity("Let me look.", TimeSpan.FromMilliseconds(900)); + _ = await handle; + + Assert.True(handle.IsInterrupted); + Assert.True(_time.WaitForTimersAsync(Start.AddSeconds(2), 0).IsCompleted, "a filler was still armed when the speech ended."); + } + + // A barge-in stops each filler on its own (filler_scheduler.py:104), so only a turn that ends on an + // unanswered call needs the round's own close. + [Fact(Timeout = 30_000)] + public async Task ATurnThatEndsOnAnUnansweredCall_ClosesItsFillerBeforeTheSpeechEnds() + { + VoiceActivity activity = Activity(new() { ["slow"] = new("still on the slow one", TimeSpan.FromSeconds(2)) }); + + SpeechHandle handle = activity.GenerateReply("Where are my orders?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look."); + await turn.CallAsync("c1", "slow"); + await _time.WaitForTimersAsync(Start.AddSeconds(2), 1); + + turn.End(); + _ = await handle; + + Assert.True(_time.WaitForTimersAsync(Start.AddSeconds(2), 0).IsCompleted, "the filler was still armed when the speech ended."); + } + + private VoiceActivity Activity(Dictionary fillers) + { + return new VoiceActivity(_session, _port, CancellationToken.None, fillers: fillers); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs new file mode 100644 index 00000000..b79d4b22 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/PipelineReplyTests.cs @@ -0,0 +1,264 @@ +// Portions derived from LiveKit Agents, tests/test_agent_session.py (test_tool_call, +// test_tool_without_a_reply_returns_the_agent_to_listening, test_interruption), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; driven over a scripted engine +// turn instead of fake VAD/STT/LLM/TTS; chat-context assertions become assertions on the engine calls. + +using System.Text.Json; +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.Domain; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// and over a scripted engine turn. + public sealed class PipelineReplyTests : IAsyncDisposable + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceSession _session; + + private readonly VoiceActivity _activity; + + private readonly AgentStateLog _states; + + public PipelineReplyTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + _activity = new VoiceActivity(_session, _port, CancellationToken.None); + _states = new AgentStateLog(_session); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // test_agent_session.py::test_tool_call + [Fact(Timeout = 30_000)] + public async Task AToolCall_SplitsTheReplyIntoTwoStepsWithOneCompletionEach() + { + SpeechHandle handle = _activity.GenerateReply("What's the weather in Tokyo?"); + ScriptedVoiceTurn turn = _port.Turn(1); + + await turn.TextAsync("Let me check the weather for you."); + await turn.CallAsync("1", "get_weather"); + await turn.ResultAsync("1"); + await turn.TextAsync("The weather in Tokyo is sunny today."); + turn.End(); + _ = await handle; + await _states.WaitForCountAsync(5); + + Assert.Equal( + [AgentState.Thinking, AgentState.Speaking, AgentState.Thinking, AgentState.Speaking, AgentState.Listening], + _states.NewStates); + Assert.Equal( + ["speak:Let me check the weather for you.", "complete", "speak:The weather in Tokyo is sunny today.", "complete"], + _output.Log); + Assert.Equal(2, handle.NumSteps); + } + + // test_agent_session.py::test_tool_without_a_reply_returns_the_agent_to_listening + [Fact(Timeout = 30_000)] + public async Task AToolWithNoReplyAfterIt_HandsTheTurnBackToListening() + { + SpeechHandle handle = _activity.GenerateReply("Look up my order"); + ScriptedVoiceTurn turn = _port.Turn(1); + + await turn.TextAsync("Let me check."); + await turn.CallAsync("1", "silent_lookup"); + await turn.ResultAsync("1"); + turn.End(); + _ = await handle; + await _states.WaitForCountAsync(4); + + Assert.Equal( + [(AgentState.Speaking, AgentState.Thinking), (AgentState.Thinking, AgentState.Listening)], + _states.Transitions.TakeLast(2)); + Assert.Equal(1, _output.Completions); + } + + [Fact(Timeout = 30_000)] + public async Task TheStepAfterATool_WaitsForItsOwnAuthorization() + { + // agent_activity.py:4024-4026 schedules the next step as a new speech turn, so a say that took + // the floor while the tool ran finishes before the next step speaks. + TaskCompletionSource sayHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); + _output.BeforeSpeakReturns = (fragment, cancellationToken) => + fragment == "One moment." ? sayHeld.Task.WaitAsync(cancellationToken) : Task.CompletedTask; + + SpeechHandle handle = _activity.GenerateReply("What's the weather?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Checking."); + await turn.CallAsync("1"); + + SpeechHandle say = _session.Say("One moment."); + await _output.WaitForLogAsync(3); + + await turn.ResultAsync("1"); + await turn.TextAsync("It is sunny."); + turn.End(); + await Poll.UntilAsync(() => handle.NumSteps == 2); + Assert.Equal(3, _output.Log.Count); + + _ = sayHeld.TrySetResult(); + _ = await say; + _ = await handle; + + Assert.Equal( + ["speak:Checking.", "complete", "speak:One moment.", "complete", "speak:It is sunny.", "complete"], + _output.Log); + } + + // test_agent_session.py::test_interruption (resume_false_interruption=False) + [Fact(Timeout = 30_000)] + public async Task ABargeInMidText_EndsTheTurnWithTheHeardTextAndTheNextReplyAnswers() + { + SpeechHandle handle = _activity.GenerateReply("Tell me a story."); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Here is a long story for you"); + await _output.WaitForLogAsync(1); + + await _activity.InterruptByAudioActivity("Here is a", TimeSpan.FromSeconds(2)); + + Assert.Equal([(0, new TurnCut("Here is a", TimeSpan.FromSeconds(2)))], _port.Cuts); + Assert.Equal(InterruptionSource.AudioActivity, handle.InterruptSource); + + SpeechHandle next = _activity.GenerateReply("Stop!"); + _port.Turn(2).End(); + _ = await next; + await _states.WaitForCountAsync(5); + + Assert.Equal( + [AgentState.Thinking, AgentState.Speaking, AgentState.Listening, AgentState.Thinking, AgentState.Listening], + _states.NewStates); + Assert.Equal(["Here is a long story for you"], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeInWhileAToolRuns_EndsTheTurnWithTheHeardText() + { + SpeechHandle handle = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look that up."); + await turn.CallAsync("1", "lookup_order"); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches); + + await _activity.InterruptByAudioActivity("Let me look", TimeSpan.FromMilliseconds(900)); + await turn.Finished; + + Assert.Equal([(0, new TurnCut("Let me look", TimeSpan.FromMilliseconds(900)))], _port.Cuts); + Assert.True(handle.IsInterrupted); + Assert.Equal(["Let me look that up."], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ATurnThatEndsWithACallThatNeverAnswered_SpeaksTheTextAfterItOnceAsItsOwnStep() + { + // A tool that throws out of the run leaves its call with no result, and the fallback line follows it + // (owner ruling 2026-09-23: the caller hears the fallback). + SpeechHandle handle = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me check."); + await turn.CallAsync("1", "lookup_order"); + await turn.TextAsync("Sorry, the order service did not answer."); + turn.End(); + _ = await handle; + + Assert.Equal( + ["speak:Let me check.", "complete", "speak:Sorry, the order service did not answer.", "complete"], + _output.Log); + Assert.Equal(2, handle.NumSteps); + Assert.Empty(_port.Cuts); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeInBeforeTheTurnEnds_SpeaksNoTextHeldBehindAnUnansweredCall() + { + SpeechHandle handle = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me check."); + await turn.CallAsync("1", "lookup_order"); + await turn.TextAsync("Sorry, the order service did not answer."); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches); + + await _activity.InterruptByAudioActivity("Let me check.", TimeSpan.FromMilliseconds(700)); + await turn.Finished; + _ = await handle; + + Assert.Equal([(0, new TurnCut("Let me check.", TimeSpan.FromMilliseconds(700)))], _port.Cuts); + Assert.True(handle.IsInterrupted); + Assert.Equal(["Let me check."], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeInBeforeTheSpeechSaidAnything_CutsNoTurnAndLeavesTheSpeechToSpeak() + { + // Turn identity (design section 3): a speech that has put no words out is not what the caller hears, + // and with no earlier reply heard there is no turn to cut. + SpeechHandle handle = _activity.GenerateReply("And the other one?"); + ScriptedVoiceTurn turn = _port.Turn(1); + _ = await turn.Started; + await Poll.UntilAsync(() => _session.Scheduler.CurrentSpeech == handle); + + await _activity.InterruptByAudioActivity("the earlier answer", TimeSpan.FromMilliseconds(1200)); + await turn.TextAsync("Still here."); + turn.End(); + _ = await handle; + + Assert.Empty(_port.Cuts); + Assert.False(handle.IsInterrupted); + Assert.Equal(["speak:Still here.", "complete"], _output.Log); + } + + [Fact(Timeout = 30_000)] + public async Task AUserTurnBeforeTheSpeechSaidAnything_CutsItsTurnWithNothingHeard() + { + // Design section 3 row "Cut before output" (W04, G1 = Cut(turn, "", null)): the turn stops and keeps the + // caller's message, and the next reply's turn follows it. + SpeechHandle handle = _activity.GenerateReply("First question"); + ScriptedVoiceTurn first = _port.Turn(1); + _ = await first.Started; + await Poll.UntilAsync(() => _session.Scheduler.CurrentSpeech == handle); + + _ = handle.Interrupt(source: InterruptionSource.UserTurn); + _ = await handle; + await first.Finished; + SpeechHandle next = _activity.GenerateReply("Second question"); + + Assert.Equal("Second question", await _port.Turn(2).Started); + await _port.Turn(2).TextAsync("The second answer."); + _port.Turn(2).End(); + _ = await next; + + Assert.Equal([(0, new TurnCut(string.Empty, null))], _port.Cuts); + Assert.Null(next.Error); + Assert.Equal(["The second answer."], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task AReplyEndingOnAPendingApproval_SpeaksTheNotice() + { + // Plan B11: a voice conversation has no surface to answer an approval on. + SpeechHandle handle = _activity.GenerateReply("Send the email"); + using JsonDocument empty = JsonDocument.Parse("{}"); + _port.LastTurn = new TurnResult(_port.ConversationId, 0, string.Empty, string.Empty, string.Empty, false, null) + { + Approvals = [new PendingApproval("req-1", "send_email", empty.RootElement.Clone())], + }; + _port.Turn(1).End(); + _ = await handle; + await _output.WaitForLogAsync(2); + + Assert.Equal(["speak:That action needs a human approval, which this conversation can't take.", "complete"], _output.Log); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs new file mode 100644 index 00000000..3dde9507 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechHandleTests.cs @@ -0,0 +1,307 @@ +// Portions derived from LiveKit Agents, tests/test_speech_handle_exception.py, +// tests/test_interrupt_protected_speech.py and tests/test_coverage_spans.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#. + +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// Error reporting, interruption and the 5 s backstop of . + public sealed class SpeechHandleTests + { + private const int SpeechNotDoneAfterInterruption = 24; + + private const int SpeechDoneCallbackFaulted = 25; + + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + // test_speech_handle_exception.py::test_await_does_not_raise_on_error + [Fact(Timeout = 30_000)] + public async Task AwaitingAFailedHandle_DoesNotRaise_AndErrorHoldsTheCause() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + handle.MarkDone(new TimeoutException("generate_reply timed out.")); + + SpeechHandle result = await handle; + Assert.Same(handle, result); + + await handle.WaitForPlayoutAsync(TestContext.Current.CancellationToken); + + TimeoutException error = Assert.IsType(handle.Error); + Assert.Equal("generate_reply timed out.", error.Message); + } + + // test_speech_handle_exception.py::test_exception_is_none_without_error + [Fact(Timeout = 30_000)] + public async Task AHandleDoneWithoutError_HasNoError() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + handle.MarkDone(); + + _ = await handle; + Assert.Null(handle.Error); + } + + // test_speech_handle_exception.py::test_exception_raises_if_not_done + [Fact] + public void ReadingErrorBeforeDone_Throws() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + + _ = Assert.Throws(() => handle.Error); + } + + // test_speech_handle_exception.py::test_error_ignored_after_done + [Fact] + public void TheFirstMarkDone_KeepsItsError() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + handle.MarkDone(new TimeoutException("first")); + handle.MarkDone(); + handle.MarkDone(new TimeoutException("second")); + + TimeoutException error = Assert.IsType(handle.Error); + Assert.Equal("first", error.Message); + } + + // test_interrupt_protected_speech.py::test_an_interrupted_protected_handle_is_left_alone + [Fact] + public void InterruptingAForceInterruptedProtectedHandle_IsANoOp() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance, allowInterruptions: false); + _ = handle.Interrupt(force: true); + + Assert.Same(handle, handle.Interrupt()); + Assert.True(handle.IsInterrupted); + handle.MarkDone(); + } + + // test_interrupt_protected_speech.py::test_a_done_protected_handle_is_left_alone + [Fact] + public void InterruptingADoneProtectedHandle_IsANoOp() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance, allowInterruptions: false); + handle.MarkDone(); + + Assert.Same(handle, handle.Interrupt()); + Assert.False(handle.IsInterrupted); + } + + // test_coverage_spans.py::test_interrupt_source_first_wins + [Fact] + public void TheFirstInterruptionSource_Wins_AndTheDefaultIsProgrammatic() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + _ = handle.Interrupt(source: InterruptionSource.AudioActivity); + _ = handle.Interrupt(source: InterruptionSource.UserTurn); + + Assert.Equal(InterruptionSource.AudioActivity, handle.InterruptSource); + Assert.Equal(InterruptionSource.Programmatic, SpeechHandle.Create(_time, NullLogger.Instance).Interrupt().InterruptSource); + } + + // speech_handle.py allow_interruptions setter: RuntimeError when disabling on an interrupted handle. + [Fact] + public void DisallowingInterruptionsOnAnInterruptedHandle_Throws() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + _ = handle.Interrupt(); + + _ = Assert.Throws(() => handle.AllowInterruptions = false); + handle.AllowInterruptions = true; + Assert.True(handle.AllowInterruptions); + } + + [Fact] + public void WithNoGeneration_WaitingForOrMarkingOne_ThrowsInvalidOperation() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + + _ = Assert.Throws(() => { _ = handle.WaitForGenerationAsync(); }); + _ = Assert.Throws(handle.MarkGenerationDone); + } + + // speech_handle.py _cancel._on_timeout: every task is cancelled, then the speech is marked done. + [Fact] + public void TheBackstop_CancelsTheTasksBeforeItMarksTheSpeechDone() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + bool? doneWhenCancelled = null; + using CancellationTokenRegistration registration = handle.TaskCancellationToken.Register( + () => doneWhenCancelled = handle.IsDone); + + _ = handle.Interrupt(); + _time.Advance(SpeechHandle.InterruptionTimeout); + + Assert.False(doneWhenCancelled); + Assert.True(handle.IsDone); + } + + [Fact(Timeout = 30_000)] + public void AnInterruptedSpeechStillRunningAfterFiveSeconds_IsCancelledAndMarkedDone() + { + RecordingLoggerFactory logs = new(); + SpeechHandle handle = SpeechHandle.Create(_time, logs.CreateLogger("speech")); + Task ignoresTheInterruption = Task.Delay(Timeout.Infinite, handle.TaskCancellationToken); + handle.AddTask(ignoresTheInterruption); + + _ = handle.Interrupt(); + _time.Advance(TimeSpan.FromSeconds(5) - TimeSpan.FromTicks(1)); + + Assert.False(handle.IsDone); + Assert.False(ignoresTheInterruption.IsCompleted); + + _time.Advance(TimeSpan.FromTicks(1)); + + Assert.True(handle.IsDone); + Assert.True(ignoresTheInterruption.IsCanceled); + CapturedLine line = Assert.Single(logs.Of(SpeechNotDoneAfterInterruption)); + Assert.Equal(LogLevel.Error, line.Level); + } + + [Fact] + public void ASpeechDoneBeforeTheBackstop_IsNeverCancelled() + { + RecordingLoggerFactory logs = new(); + SpeechHandle handle = SpeechHandle.Create(_time, logs.CreateLogger("speech")); + + _ = handle.Interrupt(); + handle.MarkDone(); + _time.Advance(TimeSpan.FromSeconds(10)); + + Assert.False(handle.TaskCancellationToken.IsCancellationRequested); + Assert.Empty(logs.Of(SpeechNotDoneAfterInterruption)); + } + + [Fact] + public void ABackstopCallbackThatRunsAfterMarkDoneDisposedItsTimer_DoesNothing() + { + RecordingLoggerFactory logs = new(); + LateTimerTimeProvider time = new(); + SpeechHandle handle = SpeechHandle.Create(time, logs.CreateLogger("speech")); + + _ = handle.Interrupt(); + handle.MarkDone(); + time.FireCapturedTimer(); + + Assert.False(handle.TaskCancellationToken.IsCancellationRequested); + Assert.Empty(logs.Of(SpeechNotDoneAfterInterruption)); + } + + [Fact(Timeout = 30_000)] + public async Task ADoneCallback_NeverRunsInsideMarkDone() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + int markDoneThread = Environment.CurrentManagedThreadId; + bool insideMarkDone = false; + TaskCompletionSource ranInline = new(TaskCreationOptions.RunContinuationsAsynchronously); + handle.AddDoneCallback(_ => ranInline.SetResult( + Volatile.Read(ref insideMarkDone) && Environment.CurrentManagedThreadId == markDoneThread)); + + Volatile.Write(ref insideMarkDone, true); + handle.MarkDone(); + Volatile.Write(ref insideMarkDone, false); + + Assert.False(await ranInline.Task); + } + + [Fact(Timeout = 30_000)] + public async Task ADoneCallbackAddedAfterDone_StillRuns_AndNotInline() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + TaskCompletionSource registeredRan = new(TaskCreationOptions.RunContinuationsAsynchronously); + handle.AddDoneCallback(_ => registeredRan.SetResult()); + handle.MarkDone(); + + // Once a registered callback ran, the callbacks registered before done have all been taken. + await registeredRan.Task; + int callerThread = Environment.CurrentManagedThreadId; + bool insideAdd = false; + TaskCompletionSource ranInline = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Volatile.Write(ref insideAdd, true); + handle.AddDoneCallback(_ => ranInline.SetResult( + Volatile.Read(ref insideAdd) && Environment.CurrentManagedThreadId == callerThread)); + Volatile.Write(ref insideAdd, false); + + Assert.False(await ranInline.Task); + } + + [Fact(Timeout = 30_000)] + public async Task AThrowingDoneCallback_IsLogged_AndTheNextOneStillRuns() + { + RecordingLoggerFactory logs = new(); + SpeechHandle handle = SpeechHandle.Create(_time, logs.CreateLogger("speech")); + TaskCompletionSource secondRan = new(TaskCreationOptions.RunContinuationsAsynchronously); + handle.AddDoneCallback(_ => throw new InvalidOperationException("boom")); + handle.AddDoneCallback(_ => secondRan.SetResult()); + + handle.MarkDone(); + await secondRan.Task; + + CapturedLine line = Assert.Single(logs.Of(SpeechDoneCallbackFaulted)); + Assert.Equal(LogLevel.Warning, line.Level); + } + + // speech_handle.py keeps its done callbacks in a set. + [Fact(Timeout = 30_000)] + public async Task ADoneCallbackRegisteredTwice_RunsOnce() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + int runs = 0; + Action counted = _ => Interlocked.Increment(ref runs); + TaskCompletionSource lastRan = new(TaskCreationOptions.RunContinuationsAsynchronously); + handle.AddDoneCallback(counted); + handle.AddDoneCallback(counted); + handle.AddDoneCallback(_ => lastRan.SetResult()); + + handle.MarkDone(); + await lastRan.Task; + + Assert.Equal(1, Volatile.Read(ref runs)); + } + + /// Hands out timers whose callback the test runs by hand, even after the handle disposed them. + private sealed class LateTimerTimeProvider : TimeProvider + { + private TimerCallback? _callback; + + private object? _state; + + public void FireCapturedTimer() + { + Assert.NotNull(_callback); + _callback(_state); + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + _callback = callback; + _state = state; + return new InertTimer(); + } + + private sealed class InertTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) + { + return true; + } + + public void Dispose() + { + // The captured callback stays runnable, as a thread-pool timer's already-queued callback does. + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs new file mode 100644 index 00000000..95970080 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechQueueTests.cs @@ -0,0 +1,73 @@ +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// The play order of : LiveKit's heap on (-priority, perf_counter_ns). + public sealed class SpeechQueueTests + { + [Fact] + public void Dequeue_TakesTheHighestPriorityFirst_AndTheFirstQueuedWithinAPriority() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + SpeechHandle low = SpeechHandle.Create(time, NullLogger.Instance); + SpeechHandle firstHigh = SpeechHandle.Create(time, NullLogger.Instance); + SpeechHandle normal = SpeechHandle.Create(time, NullLogger.Instance); + SpeechHandle secondHigh = SpeechHandle.Create(time, NullLogger.Instance); + SpeechQueue queue = new(); + queue.Enqueue(low, SpeechPriority.Low); + queue.Enqueue(firstHigh, SpeechPriority.High); + queue.Enqueue(normal, SpeechPriority.Normal); + queue.Enqueue(secondHigh, SpeechPriority.High); + + List played = []; + while (queue.TryDequeue(out SpeechHandle? speech)) + { + played.Add(speech); + } + + Assert.Equal([firstHigh, secondHigh, normal, low], played); + } + + [Fact] + public void Dequeue_KeepsQueueOrderAcrossManySpeechesOfOnePriority() + { + SpeechQueue queue = new(); + SpeechHandle[] queued = [.. Enumerable.Range(0, 5).Select(_ => Speech())]; + foreach (SpeechHandle speech in queued) + { + queue.Enqueue(speech, SpeechPriority.Normal); + } + + List played = []; + while (queue.TryDequeue(out SpeechHandle? speech)) + { + played.Add(speech); + } + + Assert.Equal(queued, played); + } + + // agent_activity.py interrupt: sorted(self._speech_q), because the heap's list order is not play order. + [Fact] + public void SortedSnapshot_ListsThePlayOrder() + { + SpeechHandle normal = Speech(); + SpeechHandle firstHigh = Speech(); + SpeechHandle secondHigh = Speech(); + SpeechQueue queue = new(); + queue.Enqueue(normal, SpeechPriority.Normal); + queue.Enqueue(firstHigh, SpeechPriority.High); + queue.Enqueue(secondHigh, SpeechPriority.High); + + Assert.Equal([firstHigh, secondHigh, normal], queue.SortedSnapshot()); + } + + private static SpeechHandle Speech() + { + return SpeechHandle.Create(new FakeTimeProvider(DateTimeOffset.UnixEpoch), NullLogger.Instance); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs new file mode 100644 index 00000000..51adc248 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerInterruptTests.cs @@ -0,0 +1,219 @@ +// Portions derived from LiveKit Agents, tests/test_interrupt_protected_speech.py, +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; realtime session removed. + +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// against speeches that disallow interruptions. + public sealed class SpeechSchedulerInterruptTests + { + private const int QueuedSpeechNotInterruptible = 26; + + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_queue_stops_at_the_protected_speech + [Fact(Timeout = 30_000)] + public async Task TheQueueWalk_StopsAtTheProtectedSpeech() + { + using RecordingLoggerFactory logs = new(); + SpeechScheduler scheduler = new(new Lock(), logs.CreateLogger("scheduler")); + SpeechHandle current = await PlayAsync(scheduler, Speech(allowInterruptions: true)); + SpeechHandle first = Queue(scheduler, Speech(allowInterruptions: true)); + SpeechHandle protectedSpeech = Queue(scheduler, Speech(allowInterruptions: false)); + SpeechHandle behind = Queue(scheduler, Speech(allowInterruptions: true)); + + _ = scheduler.Interrupt(); + + Assert.True(current.IsInterrupted); + Assert.True(first.IsInterrupted); + Assert.False(protectedSpeech.IsInterrupted); + Assert.False(behind.IsInterrupted); + Assert.Contains(logs.Of(QueuedSpeechNotInterruptible), line => line.Message.Contains("force=True", StringComparison.Ordinal)); + + await StopAsync(scheduler, current, first, protectedSpeech, behind); + } + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_a_protected_head_shields_the_whole_queue + [Fact] + public void AProtectedHead_ShieldsTheWholeQueue() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle head = Queue(scheduler, Speech(allowInterruptions: false)); + SpeechHandle middle = Queue(scheduler, Speech(allowInterruptions: true)); + SpeechHandle tail = Queue(scheduler, Speech(allowInterruptions: false)); + + _ = scheduler.Interrupt(); + + Assert.False(head.IsInterrupted); + Assert.False(middle.IsInterrupted); + Assert.False(tail.IsInterrupted); + } + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_a_cancelled_protected_speech_does_not_shield_the_queue + [Fact] + public void ACancelledProtectedSpeech_DoesNotShieldTheQueue() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle protectedSpeech = Queue(scheduler, Speech(allowInterruptions: false)); + SpeechHandle behind = Queue(scheduler, Speech(allowInterruptions: true)); + _ = protectedSpeech.Interrupt(force: true); + + _ = scheduler.Interrupt(); + + Assert.True(behind.IsInterrupted); + } + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_the_queue_is_walked_in_playout_order_not_heap_order + [Fact] + public void TheQueue_IsWalkedInPlayoutOrderNotHeapOrder() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle low = Queue(scheduler, Speech(allowInterruptions: true), SpeechPriority.Normal); + SpeechHandle urgentProtected = Queue(scheduler, Speech(allowInterruptions: false), SpeechPriority.High); + + _ = scheduler.Interrupt(); + + Assert.False(urgentProtected.IsInterrupted); + Assert.False(low.IsInterrupted); + } + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_a_protected_playing_speech_still_raises + [Fact(Timeout = 30_000)] + public async Task AProtectedPlayingSpeech_StillThrows() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle current = await PlayAsync(scheduler, Speech(allowInterruptions: false)); + + _ = Assert.Throws(() => { _ = scheduler.Interrupt(); }); + + await StopAsync(scheduler, current); + } + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_force_interrupts_the_whole_chain + [Fact(Timeout = 30_000)] + public async Task Force_InterruptsTheWholeChain() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle current = await PlayAsync(scheduler, Speech(allowInterruptions: false)); + SpeechHandle queued = Queue(scheduler, Speech(allowInterruptions: false)); + SpeechHandle behind = Queue(scheduler, Speech(allowInterruptions: true)); + + _ = scheduler.Interrupt(force: true); + + Assert.True(current.IsInterrupted); + Assert.True(queued.IsInterrupted); + Assert.True(behind.IsInterrupted); + + await StopAsync(scheduler, current, queued, behind); + } + + // test_interrupt_protected_speech.py::TestInterruptQueuedSpeeches::test_interruptible_chain_is_unaffected + [Fact(Timeout = 30_000)] + public async Task AnInterruptibleChain_IsInterruptedWhole() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle current = await PlayAsync(scheduler, Speech(allowInterruptions: true)); + SpeechHandle queued = Queue(scheduler, Speech(allowInterruptions: true)); + + _ = scheduler.Interrupt(); + + Assert.True(current.IsInterrupted); + Assert.True(queued.IsInterrupted); + + await StopAsync(scheduler, current, queued); + } + + [Fact(Timeout = 30_000)] + public async Task TheInterruptTask_CompletesOnlyWhenEveryInterruptedSpeechIsDone() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle current = await PlayAsync(scheduler, Speech(allowInterruptions: true)); + SpeechHandle queued = Queue(scheduler, Speech(allowInterruptions: true)); + + Task interrupted = scheduler.Interrupt(); + TaskCompletionSource queuedCallbacksRan = new(TaskCreationOptions.RunContinuationsAsynchronously); + queued.AddDoneCallback(_ => queuedCallbacksRan.SetResult()); + queued.MarkDone(); + + // Done callbacks run in the order they were added, so the scheduler's own has run by now. + await queuedCallbacksRan.Task; + Assert.False(interrupted.IsCompleted); + + current.MarkDone(); + await interrupted; + + await StopAsync(scheduler); + } + + // agent_activity.py _interrupt_background_speeches: only speeches that allow it, unless forced. + [Fact] + public void InterruptingBackgroundSpeeches_SkipsAProtectedOne_UnlessForced() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle open = Speech(allowInterruptions: true); + SpeechHandle protectedSpeech = Speech(allowInterruptions: false); + scheduler.AddBackgroundSpeech(open); + scheduler.AddBackgroundSpeech(protectedSpeech); + + Assert.Equal([open], scheduler.InterruptBackgroundSpeeches()); + Assert.False(protectedSpeech.IsInterrupted); + + Assert.Contains(protectedSpeech, scheduler.InterruptBackgroundSpeeches(force: true)); + Assert.True(protectedSpeech.IsInterrupted); + } + + // agent_activity.py interrupt: the background speeches are interrupted first. + [Fact] + public void Interrupt_AlsoInterruptsTheBackgroundSpeeches() + { + SpeechScheduler scheduler = NewScheduler(); + SpeechHandle background = Speech(allowInterruptions: true); + scheduler.AddBackgroundSpeech(background); + + _ = scheduler.Interrupt(); + + Assert.True(background.IsInterrupted); + } + + private static SpeechHandle Queue(SpeechScheduler scheduler, SpeechHandle speech, SpeechPriority priority = SpeechPriority.Low) + { + // force: a new scheduler is paused, and LiveKit's test pushes onto the heap with no loop running. + scheduler.ScheduleSpeech(speech, priority, force: true); + return speech; + } + + private static async Task PlayAsync(SpeechScheduler scheduler, SpeechHandle speech) + { + scheduler.ResumeScheduling(); + scheduler.ScheduleSpeech(speech, SpeechPriority.Low); + await speech.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + Assert.Same(speech, scheduler.CurrentSpeech); + return speech; + } + + private static async Task StopAsync(SpeechScheduler scheduler, params SpeechHandle[] speeches) + { + foreach (SpeechHandle speech in speeches) + { + speech.MarkDone(); + } + + await scheduler.PauseSchedulingAsync(); + } + + private static SpeechScheduler NewScheduler() + { + return new SpeechScheduler(new Lock(), NullLogger.Instance); + } + + private SpeechHandle Speech(bool allowInterruptions) + { + return SpeechHandle.Create(_time, NullLogger.Instance, allowInterruptions); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs new file mode 100644 index 00000000..ea1e8a41 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/SpeechSchedulerTests.cs @@ -0,0 +1,268 @@ +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// + /// The scheduling loop, pausing and , with expected values + /// from LiveKit's agent_activity.py (_schedule_speech, _scheduling_task, + /// _create_speech_task, wait_for_idle). + /// + public sealed class SpeechSchedulerTests + { + private const int SchedulingTaskNotRunning = 27; + + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly Lock _sessionLock = new(); + + private readonly SpeechScheduler _scheduler; + + public SpeechSchedulerTests() + { + _scheduler = new SpeechScheduler(_sessionLock, NullLogger.Instance); + } + + [Fact(Timeout = 30_000)] + public async Task TheLoop_PlaysOneSpeechAtATime_InPriorityOrder() + { + _scheduler.ResumeScheduling(); + SpeechHandle playing = Speech(); + _scheduler.ScheduleSpeech(playing, SpeechPriority.Normal); + await playing.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + SpeechHandle low = Schedule(SpeechPriority.Low); + SpeechHandle firstHigh = Schedule(SpeechPriority.High); + SpeechHandle normal = Schedule(SpeechPriority.Normal); + SpeechHandle secondHigh = Schedule(SpeechPriority.High); + + foreach (SpeechHandle next in (SpeechHandle[])[firstHigh, secondHigh, normal, low]) + { + Assert.False(next.WaitForAuthorizationAsync(TestContext.Current.CancellationToken).IsCompleted); + _scheduler.CurrentSpeech!.MarkGenerationDone(); + await next.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + Assert.Same(next, _scheduler.CurrentSpeech); + } + + await StopAsync(playing, low, firstHigh, normal, secondHigh); + } + + [Fact(Timeout = 30_000)] + public async Task AQueuedSpeechTheBackstopMarkedDone_IsSkipped() + { + _scheduler.ResumeScheduling(); + SpeechHandle playing = Speech(); + _scheduler.ScheduleSpeech(playing, SpeechPriority.Normal); + await playing.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + SpeechHandle cut = Schedule(SpeechPriority.Normal); + SpeechHandle next = Schedule(SpeechPriority.Normal); + + _ = cut.Interrupt(); + _time.Advance(TimeSpan.FromSeconds(5)); + Assert.True(cut.IsDone); + playing.MarkDone(); + + await next.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + Assert.Same(next, _scheduler.CurrentSpeech); + Assert.False(cut.HasGenerations); + + await StopAsync(playing, next); + } + + [Fact] + public void SchedulingWhilePaused_Throws_AndInterruptsTheSpeech() + { + SpeechHandle speech = Speech(); + + _ = Assert.Throws(() => _scheduler.ScheduleSpeech(speech, SpeechPriority.Normal)); + + Assert.True(speech.IsInterrupted); + Assert.False(speech.IsScheduled); + } + + [Fact(Timeout = 30_000)] + public async Task ASpeech_IsMarkedDoneOnceEveryTaskLinkedToItHasEnded() + { + SpeechHandle speech = Speech(); + TaskCompletionSource releaseFirst = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseSecond = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task first = _scheduler.CreateSpeechTask(() => releaseFirst.Task, speech); + _ = _scheduler.CreateSpeechTask(() => releaseSecond.Task, speech); + + releaseFirst.SetResult(); + await first; + Assert.False(speech.IsDone); + + releaseSecond.SetResult(); + _ = await speech; + } + + [Fact(Timeout = 30_000)] + public async Task Pausing_WaitsForEverySpeechTaskToEnd() + { + _scheduler.ResumeScheduling(); + TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously); + _ = _scheduler.CreateSpeechTask(() => release.Task); + + Task paused = _scheduler.PauseSchedulingAsync(); + Assert.False(paused.IsCompleted); + + release.SetResult(); + await paused; + } + + [Fact(Timeout = 30_000)] + public async Task WaitForIdle_CompletesAtOnceWithNoSpeech() + { + await _scheduler.WaitForIdleAsync(TestContext.Current.CancellationToken); + } + + [Fact(Timeout = 30_000)] + public async Task WaitForIdle_WaitsForTheCurrentAndTheQueuedSpeech() + { + _scheduler.ResumeScheduling(); + SpeechHandle playing = Speech(); + _scheduler.ScheduleSpeech(playing, SpeechPriority.Normal); + await playing.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + SpeechHandle queued = Schedule(SpeechPriority.Normal); + + Task idle = _scheduler.WaitForIdleAsync(TestContext.Current.CancellationToken); + playing.MarkDone(); + await queued.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + Assert.False(idle.IsCompleted); + + queued.MarkDone(); + await idle; + Assert.Null(_scheduler.CurrentSpeech); + + await StopAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task CancellingWaitForIdle_LeavesTheSpeechPlaying() + { + _scheduler.ResumeScheduling(); + SpeechHandle playing = Speech(); + _scheduler.ScheduleSpeech(playing, SpeechPriority.Normal); + await playing.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + using CancellationTokenSource cancel = new(); + + Task idle = _scheduler.WaitForIdleAsync(cancel.Token); + await cancel.CancelAsync(); + + _ = await Assert.ThrowsAnyAsync(() => idle); + Assert.False(playing.IsDone); + Assert.False(playing.WaitForGenerationAsync().IsCompleted); + Assert.Same(playing, _scheduler.CurrentSpeech); + + await StopAsync(playing); + } + + // agent_activity.py _schedule_speech: a speech scheduled once the scheduling task is done is + // cancelled with a warning, not queued. + [Fact(Timeout = 30_000)] + public async Task ASpeechScheduledAfterTheLoopEnded_IsInterruptedAndNotQueued() + { + using RecordingLoggerFactory logs = new(); + SpeechScheduler scheduler = new(new Lock(), logs.CreateLogger("scheduler")); + scheduler.ResumeScheduling(); + await scheduler.PauseSchedulingAsync(); + SpeechHandle late = Speech(); + + scheduler.ScheduleSpeech(late, SpeechPriority.Normal, force: true); + + Assert.True(late.IsInterrupted); + Assert.False(late.IsScheduled); + CapturedLine line = Assert.Single(logs.Of(SchedulingTaskNotRunning)); + Assert.Equal(LogLevel.Warning, line.Level); + } + + // agent_activity.py _scheduling_task: a speech done while queued is skipped and leaves no current speech. + [Fact(Timeout = 30_000)] + public async Task ASkippedDoneSpeech_LeavesNoCurrentSpeech() + { + SpeechHandle doneWhileQueued = Speech(); + _scheduler.ScheduleSpeech(doneWhileQueued, SpeechPriority.Normal, force: true); + doneWhileQueued.MarkDone(); + + _scheduler.ResumeScheduling(); + await _scheduler.PauseSchedulingAsync(); + + Assert.Null(_scheduler.CurrentSpeech); + } + + // agent_activity.py _on_pipeline_reply_done: a current speech that is done counts as no pending speech. + [Fact(Timeout = 30_000)] + public async Task ACurrentSpeechThatIsDone_IsNoPendingSpeech() + { + _scheduler.ResumeScheduling(); + SpeechHandle playing = Schedule(SpeechPriority.Normal); + await playing.WaitForAuthorizationAsync(TestContext.Current.CancellationToken); + + // The loop clears the current speech under this lock, so it is still current here. + lock (_sessionLock) + { + playing.MarkDone(); + Assert.Same(playing, _scheduler.CurrentSpeech); + Assert.True(_scheduler.NoPendingSpeech); + } + + await StopAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task WaitForIdle_WaitsForASpeechQueuedButNotYetCurrent() + { + SpeechHandle queued = Speech(); + _scheduler.ScheduleSpeech(queued, SpeechPriority.Normal, force: true); + + Task idle = _scheduler.WaitForIdleAsync(TestContext.Current.CancellationToken); + Assert.False(idle.IsCompleted); + + queued.MarkDone(); + _scheduler.ResumeScheduling(); + await idle; + await StopAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task CancellingWaitForIdle_WhileASpeechIsQueuedButNotCurrent_EndsTheWait() + { + SpeechHandle queued = Speech(); + _scheduler.ScheduleSpeech(queued, SpeechPriority.Normal, force: true); + using CancellationTokenSource cancel = new(); + Task idle = _scheduler.WaitForIdleAsync(cancel.Token); + + await cancel.CancelAsync(); + queued.MarkDone(); + _scheduler.ResumeScheduling(); + + _ = await Assert.ThrowsAnyAsync(() => idle); + await StopAsync(); + } + + private SpeechHandle Speech() + { + return SpeechHandle.Create(_time, NullLogger.Instance); + } + + private SpeechHandle Schedule(SpeechPriority priority) + { + SpeechHandle speech = Speech(); + _scheduler.ScheduleSpeech(speech, priority); + return speech; + } + + private async Task StopAsync(params SpeechHandle[] speeches) + { + foreach (SpeechHandle speech in speeches) + { + speech.MarkDone(); + } + + await _scheduler.PauseSchedulingAsync(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs new file mode 100644 index 00000000..bb31bd83 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TaskTeardownTests.cs @@ -0,0 +1,47 @@ +using AgentCore.AspNetCore.Voice; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// waits for every task and raises none of their faults. + public sealed class TaskTeardownTests + { + [Fact(Timeout = 30_000)] + public async Task CancelAndWaitAsync_SwallowsCancellationFromTheTokenItCancelled() + { + using CancellationTokenSource cts = new(); + Task task = Task.Delay(Timeout.Infinite, cts.Token); + + await TaskTeardown.CancelAndWaitAsync(cts, [task]); + + Assert.True(task.IsCanceled); + } + + // LiveKit utils/aio/utils.py::cancel_and_wait awaits _release_waiter, which never raises the future's error. + [Fact(Timeout = 30_000)] + public async Task CancelAndWaitAsync_DoesNotRaiseAFaultThatIsNotCancellation() + { + using CancellationTokenSource cts = new(); + Task task = Task.FromException(new InvalidOperationException("boom")); + + await TaskTeardown.CancelAndWaitAsync(cts, [task]); + + Assert.True(task.IsFaulted); + Assert.True(cts.IsCancellationRequested); + } + + // LiveKit utils/log.py::log_exceptions logs the error, then raises it again. + [Fact(Timeout = 30_000)] + public async Task LogExceptions_LogsAFaultOnce_AndRaisesItToTheOwner() + { + InvalidOperationException boom = new("boom"); + List logged = []; + Func body = TaskTeardown.LogExceptions(() => Task.FromException(boom), logged.Add); + + InvalidOperationException raised = await Assert.ThrowsAsync(body); + + Assert.Same(boom, raised); + Assert.Same(boom, Assert.Single(logged)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs new file mode 100644 index 00000000..6465411c --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TextForwardingTests.cs @@ -0,0 +1,98 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// The ordering promises its caller. + public sealed class TextForwardingTests + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + // agent_activity.py:3257 registers _on_first_frame on first_text_fut; asyncio runs that callback + // before the forwarding task's completion wakes _tts_task_impl, so the "speaking" check at + // agent_activity.py:3346 always sees it. The slow callback only makes a late one lose every time. + [Fact(Timeout = 30_000)] + public async Task OnFirstText_HasFinishedBeforeForwardingReturns() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + SpeechHandle handle = SpeechHandle.Create(time, NullLogger.Instance); + bool firstTextHandled = false; + + _ = await TextForwarding.ForwardAsync( + handle, + new FakeConversationOutput(), + Fragment("hello"), + onFirstText: () => + { + Thread.Sleep(TimeSpan.FromMilliseconds(200)); + firstTextHandled = true; + }, + handle.TaskCancellationToken); + + Assert.True(firstTextHandled); + } + + // generation.py _text_forwarding_task: a delta read after the interruption is not forwarded. + [Fact(Timeout = 30_000)] + public async Task AFragmentReadAfterTheInterruption_IsNotForwarded_AndPlaybackIsPartial() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + FakeConversationOutput output = new(); + + TextForwardingResult result = await TextForwarding.ForwardAsync( + handle, output, InterruptedBetween(handle, "Once", " upon"), onFirstText: null, handle.TaskCancellationToken); + + Assert.Equal(new TextForwardingResult("Once", TextPlayback.Partial), result); + Assert.Equal(["Once"], output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task NothingForwarded_IsSkipped() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + + TextForwardingResult result = await TextForwarding.ForwardAsync( + handle, new FakeConversationOutput(), Fragments(), onFirstText: null, handle.TaskCancellationToken); + + Assert.Equal(new TextForwardingResult(string.Empty, TextPlayback.Skipped), result); + } + + [Fact(Timeout = 30_000)] + public async Task AnEmptyFragment_CountsAsFirstText_ButNeverReachesTheOutput() + { + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + FakeConversationOutput output = new(); + int firstTextCalls = 0; + + TextForwardingResult result = await TextForwarding.ForwardAsync( + handle, output, Fragments(string.Empty, "hello", " there"), () => firstTextCalls++, handle.TaskCancellationToken); + + Assert.Equal(1, firstTextCalls); + Assert.Equal(["hello", " there"], output.Spoken); + Assert.Equal(new TextForwardingResult("hello there", TextPlayback.Full), result); + } + + private static async IAsyncEnumerable Fragment(string text) + { + yield return text; + } + + private static async IAsyncEnumerable Fragments(params string[] texts) + { + foreach (string text in texts) + { + yield return text; + } + } + + private static async IAsyncEnumerable InterruptedBetween(SpeechHandle handle, string first, string second) + { + yield return first; + _ = handle.Interrupt(); + yield return second; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs new file mode 100644 index 00000000..23b3bcf6 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/ToolFillerScopeTests.cs @@ -0,0 +1,73 @@ +// Portions derived from LiveKit Agents, tests/test_filler.py (test_run_context_with_filler_yields_and_fires +// :380, test_run_context_with_filler_cancels_on_exit :399), commit +// d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; a tool cannot open this +// itself (G5), so the scope is opened directly with the tool id's options rather than through +// RunContext.with_filler. + +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// Opening and closing around a tool call's step. + public sealed class ToolFillerScopeTests : IAsyncDisposable + { + private static readonly DateTimeOffset Start = DateTimeOffset.UnixEpoch; + + private readonly FakeTimeProvider _time = new(Start); + + private readonly FakeConversationOutput _output = new(); + + private readonly VoiceSession _session; + + private readonly SpeechHandle _handle; + + public ToolFillerScopeTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + _handle = SpeechHandle.Create(_time, NullLogger.Instance); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // test_run_context_with_filler_yields_and_fires :380 + [Fact(Timeout = 30_000)] + public async Task OpenForLongerThanTheDwell_FiresBeforeItCloses() + { + FillerOptions options = new("hello", TimeSpan.FromMilliseconds(20)); + + await using (ToolFillerScope scope = new(_session, _handle, options)) + { + await _time.WaitForTimersAsync(Start.AddMilliseconds(20), 1); + _time.Advance(TimeSpan.FromMilliseconds(20)); + await _output.WaitForLogAsync(2); + } + + Assert.Equal(["hello"], _output.Spoken); + } + + // test_run_context_with_filler_cancels_on_exit :399 + [Fact(Timeout = 30_000)] + public async Task ClosedBeforeTheDwellElapses_FiresNothingAndDisarmsTheDwell() + { + FillerOptions options = new("nope", TimeSpan.FromSeconds(1)); + + await using (ToolFillerScope scope = new(_session, _handle, options)) + { + await _time.WaitForTimersAsync(Start.AddSeconds(1), 1); + _time.Advance(TimeSpan.FromMilliseconds(10)); + } + + Assert.True(_time.WaitForTimersAsync(Start.AddSeconds(1), 0).IsCompleted, "the dwell was still armed after the scope closed."); + _time.Advance(TimeSpan.FromSeconds(1)); + Assert.Empty(_output.Spoken); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs new file mode 100644 index 00000000..9be1a497 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/TurnMetricsTests.cs @@ -0,0 +1,198 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// + /// The four latency readings of a pipeline reply, taken at LiveKit's points (plan 2.5) off a clock the + /// test owns. + /// + [Collection(ConversationTurnLatencySuite.Name)] + public sealed class TurnMetricsTests : IAsyncDisposable + { + private const string FirstToken = "agentcore.turn.time_to_first_token"; + private const string FirstSpeech = "agentcore.turn.time_to_first_speech"; + private const string ReplyEnd = "agentcore.turn.time_to_reply_end"; + private const string BargeIn = "agentcore.barge_in.latency"; + + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceActivity _activity; + + public TurnMetricsTests() + { + _activity = new VoiceActivity(new VoiceSession(_output, _time, NullLogger.Instance), _port, CancellationToken.None); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // Plan 2.5: a reply with no tool is one step, so each clock reports once, at LiveKit's points. + [Fact(Timeout = 30_000)] + public async Task AReplyThatEndsOnItsOwn_ReportsOneReadingOnEachOfTheThreeTurnClocks() + { + long userTurnEndedAt = _time.GetTimestamp(); + using LatencyReadings readings = new(); + + SpeechHandle handle = _activity.GenerateReply("one", userTurnEndedAt: userTurnEndedAt); + ScriptedVoiceTurn turn = _port.Turn(1); + _ = await turn.Started; + + _time.Advance(TimeSpan.FromMilliseconds(200)); + await turn.TextAsync("hello"); + await turn.TextAsync(" there"); + turn.End(); + _ = await handle; + + Assert.Equal([0.200], readings.Of(FirstToken)); + Assert.Equal([0.200], readings.Of(FirstSpeech)); + Assert.Equal([0.200], readings.Of(ReplyEnd)); + } + + [Fact(Timeout = 30_000)] + public async Task ATwoStepReply_ReportsATimeToFirstTokenPerStepAndOneFirstSpeechAndReplyEnd() + { + long userTurnEndedAt = _time.GetTimestamp(); + _time.Advance(TimeSpan.FromMilliseconds(50)); + using LatencyReadings readings = new(); + + SpeechHandle handle = _activity.GenerateReply("What's the weather?", userTurnEndedAt: userTurnEndedAt); + ScriptedVoiceTurn turn = _port.Turn(1); + _ = await turn.Started; + + _time.Advance(TimeSpan.FromMilliseconds(100)); + await turn.TextAsync("Let me check."); + await _output.WaitForLogAsync(1); + await turn.CallAsync("1"); + await _output.WaitForLogAsync(2); + + _time.Advance(TimeSpan.FromMilliseconds(300)); + await turn.ResultAsync("1"); + _time.Advance(TimeSpan.FromMilliseconds(150)); + await turn.TextAsync("Sunny."); + turn.End(); + _ = await handle; + + Assert.Equal([0.100, 0.150], readings.Of(FirstToken)); + Assert.Equal([0.150], readings.Of(FirstSpeech)); + Assert.Equal([0.600], readings.Of(ReplyEnd)); + } + + // EngineReplyStream.Start: no reading is taken once the speech is interrupted. + [Fact(Timeout = 30_000)] + public async Task ContentAfterTheSpeechWasInterrupted_TakesNoTimeToFirstTokenReading() + { + using LatencyReadings readings = new(); + + SpeechHandle handle = _activity.GenerateReply("Where is my order?", userTurnEndedAt: _time.GetTimestamp()); + ScriptedVoiceTurn turn = _port.Turn(1); + _ = await turn.Started; + + _time.Advance(TimeSpan.FromMilliseconds(100)); + await turn.TextAsync("Let me look."); + await turn.CallAsync("1"); + _port.EndTurnOnCut = false; + _ = handle.Interrupt(source: InterruptionSource.UserTurn); + + await turn.ResultAsync("1"); + await turn.TextAsync("It shipped."); + turn.End(); + _ = await handle; + + Assert.Equal([0.100], readings.Of(FirstToken)); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeIn_ReportsTheTimeUntilTheSpeechIsDoneAndNoReplyEnd() + { + long userTurnEndedAt = _time.GetTimestamp(); + using LatencyReadings readings = new(); + + SpeechHandle handle = _activity.GenerateReply("Tell me a story.", userTurnEndedAt: userTurnEndedAt); + ScriptedVoiceTurn turn = _port.Turn(1); + _ = await turn.Started; + + _time.Advance(TimeSpan.FromMilliseconds(80)); + await turn.TextAsync("Once upon a time"); + await _output.WaitForLogAsync(1); + + // The engine ends its turn only when the test says, so the speech stays not done meanwhile. + _port.EndTurnOnCut = false; + Task interrupted = _activity.InterruptByAudioActivity("Once", TimeSpan.FromMilliseconds(400)); + await Poll.UntilAsync(() => _port.Cuts.Count == 1); + _time.Advance(TimeSpan.FromMilliseconds(120)); + turn.End(); + await interrupted; + + Assert.True(handle.IsDone); + Assert.Equal([0.120], readings.Of(BargeIn)); + Assert.Equal([0.080], readings.Of(FirstToken)); + Assert.Equal([0.080], readings.Of(FirstSpeech)); + Assert.Empty(readings.Of(ReplyEnd)); + } + + // agent_activity.py interrupt: the barge-in reading is taken once the interrupted speech is done, also when a + // final prompt interrupted it first and it waits for this report; a repeated report takes no second reading. + [Fact(Timeout = 30_000)] + public async Task ABargeInAfterAFinalPrompt_ReportsOneReadingOnceTheSpeechIsDone() + { + using LatencyReadings readings = new(); + + SpeechHandle handle = _activity.GenerateReply("hi", userTurnEndedAt: _time.GetTimestamp()); + ScriptedVoiceTurn turn = _port.Turn(1); + _ = await turn.Started; + await turn.TextAsync("Hello there, "); + await _output.WaitForLogAsync(1); + + _port.EndTurnOnCut = false; + _activity.InterruptByFinalTranscript(); + await _time.WaitForTimersAsync(_time.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1); + Task recorded = _activity.InterruptByAudioActivity("Hello", TimeSpan.FromMilliseconds(400)); + Task repeated = _activity.InterruptByAudioActivity("Hello", TimeSpan.FromMilliseconds(400)); + _time.Advance(TimeSpan.FromMilliseconds(150)); + turn.End(); + await Task.WhenAll(recorded, repeated); + _ = await handle; + + Assert.Equal([0.150], readings.Of(BargeIn)); + } + + // generation.py perform_llm_inference: ttft is measured from a step's start, so with no step there is none. + [Fact] + public void FirstContentBeforeAnyStep_ReportsNoTimeToFirstToken() + { + using LatencyReadings readings = new(); + TurnMetrics metrics = new(_time, _time.GetTimestamp()); + + metrics.MarkFirstContent(); + + Assert.Empty(readings.Of(FirstToken)); + } + + [Fact] + public void EndingAReplyTwice_ReportsOneReplyEnd() + { + long userTurnEndedAt = _time.GetTimestamp(); + using LatencyReadings readings = new(); + TurnMetrics metrics = new(_time, userTurnEndedAt); + _time.Advance(TimeSpan.FromMilliseconds(300)); + metrics.MarkSpeechEnded(); + + metrics.EndReply(); + _time.Advance(TimeSpan.FromMilliseconds(100)); + metrics.MarkSpeechEnded(); + metrics.EndReply(); + + Assert.Equal([0.300], readings.Of(ReplyEnd)); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs new file mode 100644 index 00000000..50235364 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/UserTurnHandlerTests.cs @@ -0,0 +1,302 @@ +// Portions derived from LiveKit Agents, tests/test_wait_for_idle_cancellation.py and +// tests/test_agent_session.py (test_interim_transcript_interrupts_only_without_local_vad), +// commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; driven over a scripted +// engine turn instead of fake VAD/STT/LLM. + +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// What a committed (final) and an interim caller prompt do to the current speech and the + /// away timer, and how waits for the user-turn chain. + public sealed class UserTurnHandlerTests : IAsyncDisposable + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceSession _session; + + private readonly VoiceActivity _activity; + + private readonly UserTurnHandler _handler; + + public UserTurnHandlerTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + _activity = new VoiceActivity(_session, _port, CancellationToken.None); + _handler = new UserTurnHandler(_session, _activity); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + // agent_activity.py:2552, 2774-2783 (B1): a final transcript interrupts the current speech, as audio activity, + // and with no report of what was heard the speech cuts its engine turn to the text it forwarded (design + // section 3) once the wait runs out; the next reply's turn follows it. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscript_InterruptsTheCurrentSpeechAndTheNextReplyAnswers() + { + SpeechHandle first = _activity.GenerateReply("First question"); + ScriptedVoiceTurn turn1 = _port.Turn(1); + await turn1.TextAsync("Let me think about that."); + await _output.WaitForLogAsync(1); + + _handler.OnFinalTranscript("Second question"); + await _time.WaitForTimersAsync(_time.GetUtcNow() + VoiceOptions.DefaultHeardTextWait, 1); + _time.Advance(VoiceOptions.DefaultHeardTextWait); + _ = await first; + + Assert.True(first.IsInterrupted); + Assert.Equal(InterruptionSource.AudioActivity, first.InterruptSource); + + Assert.Equal([(0, new TurnCut("Let me think about that.", null))], _port.Cuts); + await turn1.Finished; + + Assert.Equal("Second question", await _port.Turn(2).Started); + await _port.Turn(2).TextAsync("The second answer."); + _port.Turn(2).End(); + await _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + + Assert.Equal(["Let me think about that.", "The second answer."], _output.Spoken); + } + + // agent_activity.py:2774-2780 (B4): an uninterruptible speech drops the turn instead of answering it. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptDuringAnUninterruptibleSpeech_DropsTheTurnAndLogsAWarning() + { + RecordingLoggerFactory logs = new(); + VoiceSession session = new(_output, _time, logs.CreateLogger("voice")); + VoiceActivity activity = new(session, _port, CancellationToken.None); + UserTurnHandler handler = new(session, activity); + + // Held mid-speech, not merely polled to be current: a short "Please hold." can finish + // speaking (and stop being current) before the check below ever observes it. + TaskCompletionSource held = new(TaskCreationOptions.RunContinuationsAsynchronously); + _output.BeforeSpeakReturns = (_, cancellationToken) => held.Task.WaitAsync(cancellationToken); + SpeechHandle notice = session.Say("Please hold.", allowInterruptions: false); + await _output.WaitForLogAsync(1); + + handler.OnFinalTranscript("Are you still there?"); + + await Poll.UntilAsync(() => logs.Of(30).Count == 1); + CapturedLine line = Assert.Single(logs.Of(30)); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.False(notice.IsInterrupted); + Assert.False(_port.Turn(1).Started.IsCompleted); + + _ = held.TrySetResult(); + _ = await notice; + } + + // test_agent_session.py::test_interim_transcript_interrupts_only_without_local_vad (VAD-present + // case only, :139): an interim prompt only sets state; it never interrupts (plan Q3, B8). + [Fact(Timeout = 30_000)] + public async Task AnInterimTranscript_SetsUserStateSpeakingAndNeverInterruptsTheCurrentSpeech() + { + UserStateLog states = new(_session); + SpeechHandle current = _activity.GenerateReply("Tell me about your day."); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Sure, here goes."); + await _output.WaitForLogAsync(1); + + _handler.OnInterimTranscript(); + await states.WaitForCountAsync(1); + + Assert.Equal([UserState.Speaking], states.NewStates); + Assert.False(current.IsInterrupted); + Assert.Empty(_port.Cuts); + } + + // test_wait_for_idle_cancellation.py::test_cancelling_wait_for_idle_does_not_cancel_end_of_turn_task + // (:31): our port has no separate end-of-turn task, so WaitForIdleAsync's only shielded wait is the + // user-turn task itself (agent_activity.py:2092-2094). + [Fact(Timeout = 30_000)] + public async Task CancellingWaitForIdle_DoesNotCancelTheUserTurnTask() + { + SpeechScheduler scheduler = new(new Lock(), NullLogger.Instance); + TaskCompletionSource userTurnGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + scheduler.SetUserTurnTask(userTurnGate.Task); + + using CancellationTokenSource cts = new(); + Task idle = scheduler.WaitForIdleAsync(cts.Token); + cts.Cancel(); + + await Assert.ThrowsAsync(() => idle); + Assert.False(userTurnGate.Task.IsCanceled); + + _ = userTurnGate.TrySetResult(); + await userTurnGate.Task; + } + + // test_wait_for_idle_cancellation.py::test_cancelling_wait_for_idle_does_not_poison_user_turns (:52). + [Fact(Timeout = 30_000)] + public async Task CancellingWaitForIdle_DoesNotPoisonTheNextUserTurn() + { + TaskCompletionSource staleTurn = new(TaskCreationOptions.RunContinuationsAsynchronously); + _session.Scheduler.SetUserTurnTask(staleTurn.Task); + + using CancellationTokenSource cts = new(); + Task idle = _session.Scheduler.WaitForIdleAsync(cts.Token); + cts.Cancel(); + await Assert.ThrowsAsync(() => idle); + Assert.False(staleTurn.Task.IsCanceled); + + _ = staleTurn.TrySetResult(); + await Poll.UntilAsync(() => staleTurn.Task.IsCompleted); + + _handler.OnFinalTranscript("Are you there?"); + Assert.Equal("Are you there?", await _port.Turn(1).Started); + await _port.Turn(1).TextAsync("Yes, still here."); + _port.Turn(1).End(); + + await _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + Assert.Equal(["Yes, still here."], _output.Spoken); + } + + // agent_activity.py:2723-2730: the turn waits out the one before it, and that turn's fault is its own. + [Fact(Timeout = 30_000)] + public async Task APreviousTurnThatFaults_EndsThisTurnWithThatFaultAndNoReply() + { + TaskCompletionSource previous = new(TaskCreationOptions.RunContinuationsAsynchronously); + _session.Scheduler.SetUserTurnTask(previous.Task); + _handler.OnFinalTranscript("Hello?"); + Task turn = _session.Scheduler.UserTurnTask!; + + _ = previous.TrySetException(new InvalidOperationException("the earlier turn failed")); + + InvalidOperationException fault = await Assert.ThrowsAsync(() => turn); + Assert.Equal("the earlier turn failed", fault.Message); + Assert.False(_port.Turn(1).Started.IsCompleted); + } + + // agent_activity.py:2729: a cancelled previous turn is not a fault, so this one still answers. + [Fact(Timeout = 30_000)] + public async Task APreviousTurnThatWasCancelled_StillLetsThisTurnAnswer() + { + TaskCompletionSource previous = new(TaskCreationOptions.RunContinuationsAsynchronously); + _session.Scheduler.SetUserTurnTask(previous.Task); + _handler.OnFinalTranscript("Hello?"); + Task turn = _session.Scheduler.UserTurnTask!; + + _ = previous.TrySetCanceled(TestContext.Current.CancellationToken); + await turn; + + Assert.Equal("Hello?", await _port.Turn(1).Started); + _port.Turn(1).End(); + await _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + } + + // agent_activity.py:2896-2901: a reply whose turn the caller has already moved past is interrupted, but its + // engine turn still runs, so the caller's words are kept. + [Fact(Timeout = 30_000)] + public async Task ANewerFinalTranscript_InterruptsTheOutdatedReplyAndKeepsItsWords() + { + TaskCompletionSource firstHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource secondHeld = new(TaskCreationOptions.RunContinuationsAsynchronously); + _session.Scheduler.SetUserTurnTask(firstHeld.Task); + _handler.OnFinalTranscript("First question"); + Task first = _session.Scheduler.UserTurnTask!; + _session.Scheduler.SetUserTurnTask(secondHeld.Task); + _handler.OnFinalTranscript("Second question"); + Task second = _session.Scheduler.UserTurnTask!; + + _ = firstHeld.TrySetResult(); + await first; + Assert.Equal("First question", await _port.Turn(1).Started); + _port.Turn(1).End(); + _session.Scheduler.SetUserTurnTask(null); + await _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + + Assert.Equal([(0, new TurnCut(string.Empty, null))], _port.Cuts); + Assert.Empty(_output.Spoken); + + _ = secondHeld.TrySetResult(); + await second; + Assert.Equal("Second question", await _port.Turn(2).Started); + await _port.Turn(2).TextAsync("The second answer."); + _port.Turn(2).End(); + await _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + Assert.Equal(["The second answer."], _output.Spoken); + } + + // agent_activity.py:2740-2741 and 2092-2094: a committed turn interrupts the speech still waiting on its + // tools and waits for it to finish, and wait_for_idle waits for that turn. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptWhileAToolRuns_InterruptsThatSpeechFirstAndIdleWaitsForTheTurn() + { + SpeechHandle looking = _activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn lookup = _port.Turn(1); + await lookup.TextAsync("Let me look that up."); + await lookup.CallAsync("1", "lookup_order"); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches && _session.Scheduler.CurrentSpeech is null); + _port.EndTurnOnCut = false; + + _handler.OnFinalTranscript("Never mind."); + Task turn = _session.Scheduler.UserTurnTask!; + Task speechDoneWhenTurnEnded = turn.ContinueWith( + _ => looking.IsDone, + TestContext.Current.CancellationToken, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + Task idle = _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + Assert.False(idle.IsCompleted); + + await looking.WaitIfNotInterruptedAsync([turn]); + Assert.True(looking.IsInterrupted); + Assert.False(turn.IsCompleted); + + lookup.End(); + await turn; + Assert.True(await speechDoneWhenTurnEnded, "the turn answered before the speech it interrupted was done."); + Assert.Equal("Never mind.", await _port.Turn(2).Started); + _port.Turn(2).End(); + await idle; + + Assert.Equal([(0, new TurnCut("Let me look that up.", null))], _port.Cuts); + } + + // agent_activity.py:2644-2649 (on_end_of_turn): with scheduling paused, the turn is skipped with a warning. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptWhileSchedulingIsPaused_StartsNoTurn() + { + RecordingLoggerFactory logs = new(); + VoiceSession session = new(_output, _time, logs.CreateLogger("voice")); + UserTurnHandler handler = new(session, new VoiceActivity(session, _port, CancellationToken.None)); + await session.Scheduler.PauseSchedulingAsync(); + + handler.OnFinalTranscript("Hello?"); + + CapturedLine line = Assert.Single(logs.Of(31)); + Assert.Equal(LogLevel.Warning, line.Level); + Assert.Null(session.Scheduler.UserTurnTask); + } + + // agent_activity.py:2392 and 2433: an interim prompt marks the caller speaking, a final one silent again. + [Fact(Timeout = 30_000)] + public async Task AnInterimThenAFinalTranscript_MoveTheCallerToSpeakingAndBack() + { + _handler.OnInterimTranscript(); + Assert.Equal(UserState.Speaking, _session.UserState); + Assert.False(_session.WaitForUserSilenceAsync(TestContext.Current.CancellationToken).IsCompleted); + + _handler.OnFinalTranscript("Hello?"); + Assert.Equal(UserState.Listening, _session.UserState); + Assert.True(_session.WaitForUserSilenceAsync(TestContext.Current.CancellationToken).IsCompleted); + + _port.Turn(1).End(); + await _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs new file mode 100644 index 00000000..48ede5aa --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceActivityBargeTargetTests.cs @@ -0,0 +1,123 @@ +// Portions derived from LiveKit Agents, livekit-agents/livekit/agents/voice/agent_activity.py:2310-2375 +// (_interrupt_by_audio_activity), commit d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; driven over a scripted engine turn. + +using AgentCore.Application.Runtime; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// Which speech cuts: only one still running, not + /// yet interrupted, that allows interruptions, as LiveKit's _interrupt_by_audio_activity checks. + public sealed class VoiceActivityBargeTargetTests : IAsyncDisposable + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + private readonly ScriptedVoicePort _port = new(); + + private readonly VoiceSession _session; + + public VoiceActivityBargeTargetTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeInOnASpeakingReplyThatDisallowsInterruptions_CutsNothing() + { + VoiceActivity activity = new(_session, _port, CancellationToken.None, allowInterruptions: false); + SpeechHandle reply = activity.GenerateReply("Read me the terms."); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("The terms are"); + await _output.WaitForLogAsync(1); + + await activity.InterruptByAudioActivity("The terms", TimeSpan.FromSeconds(1)); + + Assert.Empty(_port.Cuts); + Assert.False(reply.IsInterrupted); + + await turn.TextAsync(" as follows."); + turn.End(); + _ = await reply; + Assert.Equal(["The terms are", " as follows."], _output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ABargeInWhileAReplyThatDisallowsInterruptionsWaitsOnATool_CutsNothing() + { + VoiceActivity activity = new(_session, _port, CancellationToken.None, allowInterruptions: false); + SpeechHandle reply = activity.GenerateReply("Where is my order?"); + ScriptedVoiceTurn turn = _port.Turn(1); + await turn.TextAsync("Let me look that up."); + await turn.CallAsync("1", "lookup_order"); + await Poll.UntilAsync(() => _session.HasBackgroundSpeeches && _session.Scheduler.CurrentSpeech is null); + + await activity.InterruptByAudioActivity("Let me look", TimeSpan.FromMilliseconds(900)); + + Assert.Empty(_port.Cuts); + Assert.False(reply.IsInterrupted); + + await turn.ResultAsync("1"); + await turn.TextAsync("It shipped."); + turn.End(); + _ = await reply; + Assert.Equal(["Let me look that up.", "It shipped."], _output.Spoken); + } + + // Handoff 2026-09-22, step 7 open issues: the first cut wins, so a transport report that arrives once a + // user turn has already interrupted the speech is refused. + [Fact(Timeout = 30_000)] + public async Task ABargeInOnAReplyAlreadyInterrupted_LeavesTheCutToThatInterruption() + { + TaskCompletionSource held = new(TaskCreationOptions.RunContinuationsAsynchronously); + _output.BeforeSpeakReturns = (_, _) => held.Task; + VoiceActivity activity = new(_session, _port, CancellationToken.None); + SpeechHandle reply = activity.GenerateReply("Tell me a story."); + await _port.Turn(1).TextAsync("Here is a long story"); + await _output.WaitForLogAsync(1); + _ = reply.Interrupt(source: InterruptionSource.UserTurn); + + Task barge = activity.InterruptByAudioActivity("Here is", TimeSpan.FromSeconds(1)); + + Assert.Empty(_port.Cuts); + + _ = held.TrySetResult(); + await barge; + _ = await reply; + Assert.Equal([(0, new TurnCut("Here is a long story", null))], _port.Cuts); + Assert.Equal(InterruptionSource.UserTurn, reply.InterruptSource); + } + + // VoiceActivity.InterruptByAudioActivity: the engine holds the cut before the transport's next frame is read, + // not once the speech's own teardown runs. + [Fact(Timeout = 30_000)] + public async Task ABargeIn_CutsTheTurnBeforeItReturns() + { + TaskCompletionSource held = new(TaskCreationOptions.RunContinuationsAsynchronously); + _output.BeforeSpeakReturns = (_, _) => held.Task; + VoiceActivity activity = new(_session, _port, CancellationToken.None); + SpeechHandle reply = activity.GenerateReply("Tell me a story."); + await _port.Turn(1).TextAsync("Here is a long story"); + await _output.WaitForLogAsync(1); + + Task recorded = activity.InterruptByAudioActivity("Here is a", TimeSpan.FromSeconds(2)); + + Assert.Equal([(0, new TurnCut("Here is a", TimeSpan.FromSeconds(2)))], _port.Cuts); + + _ = held.TrySetResult(); + await recorded; + _ = await reply; + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs new file mode 100644 index 00000000..7cbed820 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceConversationLoopTests.cs @@ -0,0 +1,183 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Sessions.Memory; +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Tests.Vendors.TelnyxRelay; +using AgentCore.AspNetCore.Voice; +using AgentCore.Domain.Audit; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// over real conversation sessions. + public sealed class VoiceConversationLoopTests + { + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + // A second Started mid-speech lets the running speech finish on its own conversation, and the next turn + // runs on the new one. Nothing is held. + [Fact(Timeout = 30_000)] + public async Task ASecondConversationNamedMidSpeech_LetsTheSpeechFinishAndTheNextTurnRunsOnTheNewOne() + { + using HeldPromptChatClient reply = new("first reply", "second reply"); + FakeConversationOutput output = new(); + (VoiceLoopHarness harness, Task running) = VoiceLoopHarness.Start(reply, output); + + harness.Send(new ConversationInput.Started("conversation-replaced")); + harness.Send(new ConversationInput.Utterance("one", "en", IsFinal: true)); + await reply.WaitUntilFirstTurnStreamingAsync().WaitAsync(Ct); + ConversationSession replaced = (await harness.Sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-replaced", Ct))!; + + harness.Send(new ConversationInput.Started("conversation-named")); + await Poll.UntilAsync(() => harness.Loop.ConversationId == "conversation-named"); + reply.ReleaseFirstTurn(); + await Poll.UntilAsync(() => output.Completions == 1); + + harness.Send(new ConversationInput.Utterance("two", "en", IsFinal: true)); + await reply.SecondTurnStarted.Task.WaitAsync(Ct); + reply.ReleaseSecondTurn(); + await Poll.UntilAsync(() => output.Completions == 2); + harness.Complete(); + await running; + await harness.Loop.DrainAsync(); + + ConversationSession named = (await harness.Sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-named", Ct))!; + // "two" may still find the first speech current once its words are out, and interrupt it there; the + // turn then keeps every word it forwarded, so either way its reply is whole. + Assert.Equal("first reply", replaced.LastTurn!.ReplyText); + Assert.Equal(["two", "second reply"], named.Transcript.Select(message => message.Text)); + Assert.Equal("first reply" + "second reply", string.Concat(output.Spoken)); + } + + // A barge-in before any conversation started has no speech to cut, so the loop reads on. + [Fact(Timeout = 30_000)] + public async Task ABargeInBeforeTheConversationStarted_IsIgnored() + { + using HeldPromptChatClient reply = new("first reply", "second reply"); + FakeConversationOutput output = new(); + (VoiceLoopHarness harness, Task running) = VoiceLoopHarness.Start(reply, output); + + harness.Send(new ConversationInput.Barge("hello", TimeSpan.FromMilliseconds(300))); + harness.Complete(); + await running; + + Assert.Equal(0, output.Stops); + Assert.False(harness.Loop.HasStarted); + } + + // The caller hung up on a call this loop's session had already been idle-unloaded out from under, and + // a second call reopened the same id under the same entry in between. The stale hang-up must leave the + // newer session, its folder, and the audit chain it is writing to alone. + [Fact(Timeout = 30_000)] + public async Task AHangUpFromAStaleLoopLeavesANewerSessionOnTheSameIdUntouched() + { + TimeSpan idleTimeout = TimeSpan.FromMinutes(1); + FakeTimeProvider clock = new(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); + string root = Path.Combine(Path.GetTempPath(), "agentcore-voice-p1-" + Guid.NewGuid().ToString("N")); + + try + { + RecordingObserver observer = new(); + using HeldPromptChatClient reply = new("unused", "unused"); + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(TelnyxRelayTurnTests.PolicyYaml); + RoutingChatClientFactory chatClients = new(reply); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, new AgentCompilationContext(chatClients) { WorkspaceRoot = root })[SingleEntrySessionFactories.MainEntry]; + ConversationSessionFactory factory = new( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + extractor: null, + timeProvider: clock, + observers: [observer], + workspaceRoot: root); + + using InMemoryConversationSessions sessions = new(SingleEntrySessionFactories.Of(factory), idleTimeout, clock); + + VoiceConversationLoop loopA = NewLoop(sessions, clock, new FakeConversationOutput()); + await loopA.RunAsync(OneAsync(new ConversationInput.Started("conversation-1"))); + + ConversationSession stale = (await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Ct))!; + + clock.Advance(idleTimeout); + await Poll.UntilAsync(() => !Directory.Exists(stale.Workspace!)); + + VoiceConversationLoop loopB = NewLoop(sessions, clock, new FakeConversationOutput()); + await loopB.RunAsync(OneAsync(new ConversationInput.Started("conversation-1"))); + + ConversationSession fresh = (await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Ct))!; + Assert.NotSame(stale, fresh); + + string sentinel = Path.Combine(fresh.Workspace!, "sentinel.txt"); + await File.WriteAllTextAsync(sentinel, "loop B's file", Ct); + + await loopA.EndAsync(ConversationEndReason.CallerHungUp); + + Assert.True(File.Exists(sentinel), "the stale hang-up must not delete the newer session's folder."); + Assert.Same(fresh, await sessions.TryGetAsync(SingleEntrySessionFactories.MainEntry, "conversation-1", Ct)); + Assert.DoesNotContain(ConversationEventKind.ConversationEnded, observer.Kinds); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static VoiceConversationLoop NewLoop(InMemoryConversationSessions sessions, TimeProvider clock, IConversationOutputPort output) + { + VoiceConversationLoop? loop = null; + loop = new VoiceConversationLoop( + sessions, + SingleEntrySessionFactories.MainEntry, + output, + new ConnectionTaskObserver(() => loop?.ConversationId ?? "(none)", (_, _) => { }, (_, _, _) => { }, (_, _, _) => false), + clock, + NullLogger.Instance, + TimeSpan.FromSeconds(5), + CancellationToken.None, + new VoiceOptions(UserAway: null, new Dictionary())); + return loop; + } + + private static async IAsyncEnumerable OneAsync(ConversationInput input) + { + await Task.Yield(); + yield return input; + } + + private sealed class RecordingObserver : IConversationObserver + { + private readonly List _kinds = []; + + public IReadOnlyList Kinds + { + get + { + lock (_kinds) + { + return [.. _kinds]; + } + } + } + + public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken = default) + { + lock (_kinds) + { + _kinds.Add(conversationEvent.Kind); + } + + return ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs new file mode 100644 index 00000000..c8f6ea2e --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceOptionsBinderTests.cs @@ -0,0 +1,202 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.AspNetCore.Voice; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// + /// providers.conversation's userAway and filler reach , + /// and a value a wait would refuse stops the start with a pointer (plan + /// step 8, owner rulings 1 and 2). + /// + public sealed class VoiceOptionsBinderTests + { + private static ConversationProviderConfiguration Entry( + JsonElement userAway = default, + IReadOnlyDictionary? filler = null) + { + return new() + { + Kind = "telnyx-relay", + UserAway = userAway, + Filler = filler ?? ReadOnlyDictionary.Empty, + }; + } + + private static JsonElement Json(string text) + { + return JsonDocument.Parse(text).RootElement; + } + + [Fact] + public void AnOmittedUserAwayKeepsTheShippedDefault() + { + VoiceOptions options = VoiceOptionsBinder.Build(Entry()); + + Assert.Equal(VoiceOptions.Default.UserAway, options.UserAway); + } + + [Fact] + public void AnExplicitNullTurnsTheAwayPromptOff() + { + VoiceOptions options = VoiceOptionsBinder.Build(Entry(userAway: Json("null"))); + + Assert.Null(options.UserAway); + } + + [Fact] + public void AnObjectOverridesTheAwayPrompt() + { + VoiceOptions options = VoiceOptionsBinder.Build( + Entry(userAway: Json("""{"timeoutSeconds": 30, "say": "Still there?"}"""))); + + Assert.NotNull(options.UserAway); + Assert.Equal(TimeSpan.FromSeconds(30), options.UserAway!.Timeout); + Assert.Equal("Still there?", options.UserAway.Say); + } + + [Fact] + public void AnAwayTimeoutPastTheTimerCeilingFailsTheStartWithAPointer() + { + ConfigurationLoadException failure = Assert.Throws( + () => VoiceOptionsBinder.Build(Entry(userAway: Json("""{"timeoutSeconds": 5000000, "say": "hi"}""")))); + + Assert.Equal("/providers/conversation/userAway/timeoutSeconds", failure.Errors[0].Pointer); + } + + [Fact] + public void NoFillerKeyOpensNoFiller() + { + VoiceOptions options = VoiceOptionsBinder.Build(Entry()); + + Assert.Empty(options.Fillers); + } + + [Fact] + public void AFillerEntryReachesTheOptionsByToolId() + { + Dictionary filler = new(StringComparer.Ordinal) + { + ["lookup_order"] = new() + { + Say = "One moment", + DelaySeconds = 1.5, + IntervalSeconds = 5, + MaxSteps = 2, + }, + }; + + VoiceOptions options = VoiceOptionsBinder.Build(Entry(filler: filler)); + + FillerOptions built = Assert.Single(options.Fillers).Value; + Assert.Equal("One moment", built.Source.Resolve(0)); + Assert.Equal(TimeSpan.FromSeconds(1.5), built.Delay); + Assert.Equal(TimeSpan.FromSeconds(5), built.Interval); + Assert.Equal(2, built.MaxSteps); + } + + [Fact] + public void AFillerWithNoIntervalOrCapFiresAtMostOnceWithNoLimit() + { + Dictionary filler = new(StringComparer.Ordinal) + { + ["lookup_order"] = new() { Say = "One moment", DelaySeconds = 1 }, + }; + + VoiceOptions options = VoiceOptionsBinder.Build(Entry(filler: filler)); + + FillerOptions built = options.Fillers["lookup_order"]; + Assert.Null(built.Interval); + Assert.Null(built.MaxSteps); + } + + [Fact] + public void AFillerDelayPastTheTimerCeilingFailsTheStartWithAPointer() + { + Dictionary filler = new(StringComparer.Ordinal) + { + ["lookup_order"] = new() { Say = "One moment", DelaySeconds = 5_000_000 }, + }; + + ConfigurationLoadException failure = Assert.Throws( + () => VoiceOptionsBinder.Build(Entry(filler: filler))); + + Assert.Equal("/providers/conversation/filler/lookup_order/delaySeconds", failure.Errors[0].Pointer); + } + + [Fact] + public void AFillerIntervalPastTheTimerCeilingFailsTheStartWithItsOwnPointer() + { + Dictionary filler = new(StringComparer.Ordinal) + { + ["lookup_order"] = new() { Say = "One moment", DelaySeconds = 1, IntervalSeconds = 5_000_000 }, + }; + + ConfigurationLoadException failure = Assert.Throws( + () => VoiceOptionsBinder.Build(Entry(filler: filler))); + + Assert.Equal("/providers/conversation/filler/lookup_order/intervalSeconds", failure.Errors[0].Pointer); + } + + [Theory] + [InlineData(1e20)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(-1.0)] + [InlineData(0.0)] + public void AFillerDelayOutsideTheTimerRangeIsARangeErrorAtItsPointer(double seconds) + { + Dictionary filler = new(StringComparer.Ordinal) + { + ["lookup_order"] = new() { Say = "One moment", DelaySeconds = seconds }, + }; + + ConfigurationLoadException failure = Assert.Throws( + () => VoiceOptionsBinder.Build(Entry(filler: filler))); + + ConfigurationError error = Assert.Single(failure.Errors); + Assert.Equal("/providers/conversation/filler/lookup_order/delaySeconds", error.Pointer); + Assert.Equal(ConfigurationCheck.ValueRange, error.Check); + } + + [Theory] + [InlineData(1e20)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(-1.0)] + public void AFillerIntervalOutsideTheTimerRangeIsARangeErrorAtItsPointer(double seconds) + { + Dictionary filler = new(StringComparer.Ordinal) + { + ["lookup_order"] = new() { Say = "One moment", DelaySeconds = 1, IntervalSeconds = seconds }, + }; + + ConfigurationLoadException failure = Assert.Throws( + () => VoiceOptionsBinder.Build(Entry(filler: filler))); + + ConfigurationError error = Assert.Single(failure.Errors); + Assert.Equal("/providers/conversation/filler/lookup_order/intervalSeconds", error.Pointer); + Assert.Equal(ConfigurationCheck.ValueRange, error.Check); + } + + [Theory] + [InlineData("1e20")] + [InlineData("-1")] + [InlineData("0")] + public void AnAwayTimeoutOutsideTheTimerRangeIsARangeErrorAtItsPointer(string seconds) + { + JsonElement userAway = Json($$"""{"timeoutSeconds": {{seconds}}, "say": "hi"}"""); + + ConfigurationLoadException failure = Assert.Throws( + () => VoiceOptionsBinder.Build(Entry(userAway: userAway))); + + ConfigurationError error = Assert.Single(failure.Errors); + Assert.Equal("/providers/conversation/userAway/timeoutSeconds", error.Pointer); + Assert.Equal(ConfigurationCheck.ValueRange, error.Check); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs new file mode 100644 index 00000000..a1f7fdc1 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayCloseTests.cs @@ -0,0 +1,125 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// The away timer around close and around a callback .NET dispatched before a disarm. An + /// unhandled throw on a thread ends the process, so no away + /// callback may throw, and none may act once its timer is gone (LiveKit's aclose disarms the + /// timer last, agent_session.py:1329). + public sealed class VoiceSessionAwayCloseTests : IAsyncDisposable + { + private static readonly UserAwayOptions Away = new(TimeSpan.FromSeconds(15), "Are you still there?"); + + private readonly FakeConversationOutput _output = new() + { + BeforeSpeakReturns = (_, ct) => Task.Delay(Timeout.Infinite, ct), + }; + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task ClosingMidSpeech_LeavesTheAwayTimerDisarmed() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + VoiceSession session = new(_output, time, NullLogger.Instance, Away); + session.Start(); + _ = session.Say("hello"); + await _output.WaitForLogAsync(1); + await Poll.UntilAsync(() => session.AgentState == AgentState.Speaking); + + await session.CloseAsync(); + await Poll.UntilAsync(() => session.AgentState == AgentState.Listening); + + Exception? thrown = Record.Exception(() => time.Advance(TimeSpan.FromSeconds(15))); + + Assert.Null(thrown); + Assert.Equal(UserState.Listening, session.UserState); + } + + // Unfixed, this does not fail: it ends the test process. + [Fact(Timeout = 30_000)] + public async Task ClosingMidSpeech_OnTheRealClock_DoesNotEndTheProcess() + { + VoiceSession session = new( + _output, TimeProvider.System, NullLogger.Instance, new UserAwayOptions(TimeSpan.FromMilliseconds(300), Away.Say)); + session.SetAgentState(AgentState.Thinking); + _ = session.Say("hello"); + await _output.WaitForLogAsync(1); + + await session.CloseAsync(); + await Task.Delay(1500, TestContext.Current.CancellationToken); + + Assert.Equal(UserState.Listening, session.UserState); + } + + [Fact(Timeout = 30_000)] + public void ALateCallbackOfADisarmedTimer_LeavesASpeakingCallerSpeaking() + { + CapturingTimeProvider time = new(); + VoiceSession session = new(_output, time, NullLogger.Instance, Away); + session.Start(); + CapturedTimer armed = Assert.Single(time.Timers); + + session.SetUserState(UserState.Speaking); + armed.Fire(); + + Assert.Equal(UserState.Speaking, session.UserState); + Assert.Empty(_output.Spoken); + } + + [Fact(Timeout = 30_000)] + public void ALateCallbackOfAReplacedTimer_DoesNothing() + { + CapturingTimeProvider time = new(); + VoiceSession session = new(_output, time, NullLogger.Instance, Away); + session.Start(); + CapturedTimer first = time.Timers[0]; + + session.RefreshUserAwayOnFinalTranscript(); + first.Fire(); + + Assert.Equal(2, time.Timers.Count); + Assert.Equal(UserState.Listening, session.UserState); + } + + // The window between the callback's closed check and its Say: scheduling already paused. + [Fact(Timeout = 30_000)] + public async Task AnAwayPromptThatCannotBeScheduled_IsLoggedNotThrown() + { + CapturingTimeProvider time = new(); + RecordingLoggerFactory logs = new(); + VoiceSession session = new(_output, time, logs.CreateLogger("voice"), Away); + session.Start(); + await session.Scheduler.PauseSchedulingAsync(); + + Exception? thrown = Record.Exception(time.Timers[0].Fire); + + Assert.Null(thrown); + Assert.Equal(UserState.Away, session.UserState); + _ = Assert.Single(logs.Of(33)); + } + + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptWhileAway_BringsTheCallerBackToListening() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + FakeConversationOutput output = new(); + VoiceSession session = new(output, time, NullLogger.Instance, Away); + session.Start(); + time.Advance(Away.Timeout); + await Poll.UntilAsync(() => session.UserState == UserState.Away); + + session.RefreshUserAwayOnFinalTranscript(); + + Assert.Equal(UserState.Listening, session.UserState); + await output.DisposeAsync(); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs new file mode 100644 index 00000000..106823d8 --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionAwayTests.cs @@ -0,0 +1,215 @@ +// Portions derived from LiveKit Agents, tests/test_agent_session.py +// (test_final_transcript_resets_away_timer_when_not_speaking, :1514), commit +// d8405f132e1bd960f298190c18daf81ffc1faf45. Copyright 2023 LiveKit, Inc. +// Licensed under the Apache License, Version 2.0. Modified: translated to C#; LiveKit's test mocks +// `_set_user_away_timer` and asserts on the mock. Ours has no mock of that shape, so each case is +// proven behaviourally: whether the away window the test observes is the one that started at the +// state transition, or a fresh one that started at the final transcript. + +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// When the caller counts as away (plan B7), and what a final and an interim transcript each do + /// to that window (LiveKit's _user_input_transcribed, agent_session.py:2254-2267). + public sealed class VoiceSessionAwayTests : IAsyncDisposable + { + private static readonly UserAwayOptions Away = new(TimeSpan.FromSeconds(15), "Are you still there?"); + + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task BothSidesListeningForTheFullTimeout_MarksTheCallerAwayAndSpeaksThePrompt() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + UserStateLog states = new(session); + + session.SetAgentState(AgentState.Thinking); + session.SetAgentState(AgentState.Listening); + + _time.Advance(TimeSpan.FromSeconds(15) - TimeSpan.FromTicks(1)); + Assert.Equal(UserState.Listening, session.UserState); + + _time.Advance(TimeSpan.FromTicks(1)); + await _output.WaitForLogAsync(2); + + Assert.Equal([UserState.Away], states.NewStates); + Assert.Equal(["Are you still there?"], _output.Spoken); + } + + // agent_session.py:2025-2027 (_RunningTasks guard): a tool call still open holds the window open. + [Fact(Timeout = 30_000)] + public void AToolCallStillOpen_NeverArmsTheAwayTimer() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + SpeechHandle background = SpeechHandle.Create(_time, NullLogger.Instance); + session.Scheduler.AddBackgroundSpeech(background); + + session.SetAgentState(AgentState.Thinking); + session.SetAgentState(AgentState.Listening); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Listening, session.UserState); + Assert.Empty(_output.Spoken); + } + + // test_final_transcript_resets_away_timer_when_not_speaking: final transcript, both sides listening. + [Fact(Timeout = 30_000)] + public async Task AFinalTranscriptWhileBothSidesListen_RestartsTheAwayWindow() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.SetAgentState(AgentState.Thinking); + session.SetAgentState(AgentState.Listening); + + _time.Advance(TimeSpan.FromSeconds(10)); + session.RefreshUserAwayOnFinalTranscript(); + _time.Advance(TimeSpan.FromSeconds(10)); + + // 20 s since the state transition, but only 10 s since the reset: still listening. + Assert.Equal(UserState.Listening, session.UserState); + + _time.Advance(TimeSpan.FromSeconds(5)); + await Poll.UntilAsync(() => session.UserState == UserState.Away); + } + + // test_final_transcript_resets_away_timer_when_not_speaking (is_final=False half): an interim + // transcript never calls the away-timer reset LiveKit's mock asserts on. Ours has no such reset + // call for an interim either — OnInterimTranscript only ever moves the caller to "speaking", + // which cancels a pending countdown the same way any non-listening transition does (B8). + [Fact] + public void AnInterimTranscript_CancelsAPendingAwayCountdownInsteadOfResettingIt() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + VoiceActivity activity = new(session, new ScriptedVoicePort(), CancellationToken.None); + UserTurnHandler handler = new(session, activity); + + session.SetAgentState(AgentState.Thinking); + session.SetAgentState(AgentState.Listening); + + _time.Advance(TimeSpan.FromSeconds(10)); + handler.OnInterimTranscript(); + _time.Advance(TimeSpan.FromSeconds(10)); + + // 20 s since the state transition, but the interim moved the caller to "speaking" at the + // 10 s mark, cancelling the countdown; nothing re-arms it until a final transcript returns + // the caller to "listening". + Assert.Equal(UserState.Speaking, session.UserState); + Assert.Empty(_output.Spoken); + } + + // test_final_transcript_resets_away_timer_when_not_speaking: final transcript while speaking is a no-op. + [Fact] + public void AFinalTranscriptWhileTheCallerIsSpeaking_NeverArmsTheAwayTimer() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.SetUserState(UserState.Speaking); + + session.RefreshUserAwayOnFinalTranscript(); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Speaking, session.UserState); + } + + // agent_session.py:2166-2169 (_update_agent_state): only "listening" on both sides arms the window. + [Fact] + public void TheAgentBackToListeningWhileTheCallerSpeaks_NeverArmsTheAwayTimer() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.SetUserState(UserState.Speaking); + + session.SetAgentState(AgentState.Thinking); + session.SetAgentState(AgentState.Listening); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Speaking, session.UserState); + Assert.Empty(_output.Spoken); + } + + // agent_session.py:2166-2169: any other agent state cancels the countdown. + [Fact] + public void TheAgentStartingToSpeak_CancelsThePendingAwayCountdown() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.Start(); + + session.SetAgentState(AgentState.Speaking); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Listening, session.UserState); + Assert.Empty(_output.Spoken); + } + + // agent_session.py:2223-2226 (_update_user_state): the caller falling silent while the agent speaks arms nothing. + [Fact] + public void TheCallerFallingSilentWhileTheAgentSpeaks_NeverArmsTheAwayTimer() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.SetAgentState(AgentState.Speaking); + + session.SetUserState(UserState.Speaking); + session.SetUserState(UserState.Listening); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Listening, session.UserState); + Assert.Empty(_output.Spoken); + } + + // agent_session.py:2263-2265 (_user_input_transcribed): a final transcript refreshes the window only while + // the agent listens too. + [Fact] + public void AFinalTranscriptWhileTheAgentThinks_NeverArmsTheAwayTimer() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.SetAgentState(AgentState.Thinking); + + session.RefreshUserAwayOnFinalTranscript(); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Listening, session.UserState); + Assert.Empty(_output.Spoken); + } + + // agent_session.py:1159-1160: the session starts with the agent listening, which arms the window only + // while the caller listens too. + [Fact] + public void StartingWhileTheCallerSpeaks_NeverArmsTheAwayTimer() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + session.SetUserState(UserState.Speaking); + + session.Start(); + _time.Advance(TimeSpan.FromSeconds(30)); + + Assert.Equal(UserState.Speaking, session.UserState); + Assert.Empty(_output.Spoken); + } + + // agent_session.py:2195-2196: setting the state the caller is already in changes nothing, not even the window. + [Fact(Timeout = 30_000)] + public async Task SettingTheUserStateItAlreadyHas_RaisesNothingAndKeepsTheWindow() + { + VoiceSession session = new(_output, _time, NullLogger.Instance, Away); + UserStateLog states = new(session); + session.Start(); + _time.Advance(TimeSpan.FromSeconds(10)); + + session.SetUserState(UserState.Listening); + Assert.Empty(states.NewStates); + + _time.Advance(TimeSpan.FromSeconds(5)); + Assert.Equal([UserState.Away], states.NewStates); + await _output.WaitForLogAsync(2); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs new file mode 100644 index 00000000..c495fbbb --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionCloseTests.cs @@ -0,0 +1,79 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// : LiveKit's _teardown_activity without drain + /// (agent_session.py:1349-1386) closes the session, force-interrupts every speech and stops + /// scheduling. + public sealed class VoiceSessionCloseTests : IAsyncDisposable + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + private readonly FakeConversationOutput _output = new(); + + private readonly VoiceSession _session; + + public VoiceSessionCloseTests() + { + _session = new VoiceSession(_output, _time, NullLogger.Instance); + } + + public ValueTask DisposeAsync() + { + return _output.DisposeAsync(); + } + + [Fact(Timeout = 30_000)] + public async Task WaitForIdle_AfterClose_Throws() + { + await _session.CloseAsync(); + + _ = await Assert.ThrowsAsync( + () => _session.WaitForIdleAsync(TestContext.Current.CancellationToken)); + } + + [Fact(Timeout = 30_000)] + public async Task WaitForIdle_MidSpeech_ThrowsWhenTheSessionClosesWithoutWaitingForTheSpeech() + { + TaskCompletionSource held = new(TaskCreationOptions.RunContinuationsAsynchronously); + _output.BeforeSpeakReturns = (_, _) => held.Task; + SpeechHandle speech = _session.Say("hello"); + await _output.WaitForLogAsync(1); + Task idle = _session.WaitForIdleAsync(TestContext.Current.CancellationToken); + + _session.MarkClosed(); + + _ = await Assert.ThrowsAsync(() => idle); + Assert.False(speech.IsDone); + + _ = held.TrySetResult(); + _ = await speech; + } + + [Fact(Timeout = 30_000)] + public async Task Close_InterruptsASpeechThatDisallowsInterruptions() + { + _output.BeforeSpeakReturns = (_, cancellationToken) => Task.Delay(Timeout.Infinite, cancellationToken); + SpeechHandle notice = _session.Say("Please hold.", allowInterruptions: false); + await _output.WaitForLogAsync(1); + + await _session.CloseAsync(); + + Assert.True(notice.IsInterrupted); + Assert.True(notice.IsDone); + } + + [Fact(Timeout = 30_000)] + public async Task Say_AfterClose_IsRefused() + { + await _session.CloseAsync(); + + _ = Assert.Throws(() => _session.Say("too late")); + Assert.Empty(_output.Spoken); + } + } +} diff --git a/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs new file mode 100644 index 00000000..8b5c89ba --- /dev/null +++ b/tests/AgentCore.AspNetCore.Tests/Voice/VoiceSessionTests.cs @@ -0,0 +1,217 @@ +using AgentCore.AspNetCore.Tests.Fakes; +using AgentCore.AspNetCore.Voice; +using AgentCore.TestSupport; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AgentCore.AspNetCore.Tests.Voice +{ + /// , and the agent + /// state transitions LiveKit's _update_agent_state/_on_pipeline_reply_done drive around + /// a say. + public sealed class VoiceSessionTests + { + private readonly FakeTimeProvider _time = new(DateTimeOffset.UnixEpoch); + + [Fact(Timeout = 30_000)] + public async Task Say_SpeaksTheTextAndClosesTheReply() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + + SpeechHandle handle = session.Say("hello there"); + _ = await handle; + + Assert.Equal(["hello there"], output.Spoken); + Assert.Equal(1, output.Completions); + } + + // test_agent_session.py::test_tool_call (states half): agent state follows _update_agent_state. + [Fact(Timeout = 30_000)] + public async Task Say_MovesAgentStateThroughThinkingSpeakingListening() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + List transitions = []; + session.AgentStateChanged += change => transitions.Add(change.NewState); + + // A filler or a notice plays while a pipeline reply's tool round has the agent "thinking" + // (B5); simulate that starting point directly, since generate_reply is step 5. + session.SetAgentState(AgentState.Thinking); + + SpeechHandle handle = session.Say("one moment"); + _ = await handle; + + Assert.Equal([AgentState.Thinking, AgentState.Speaking, AgentState.Listening], transitions); + } + + [Fact(Timeout = 30_000)] + public async Task AnInterruptedSay_ForwardsNothingFurther() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + + // Never authorized: the only way SayReply's authorization wait can resolve is the + // interruption itself, so this is deterministic regardless of thread-pool timing. + Task run = SayReply.RunAsync(handle, session, output, Fragment("hello"), handle.TaskCancellationToken); + _ = handle.Interrupt(); + await run; + + Assert.Empty(output.Spoken); + Assert.Equal(0, output.Completions); + } + + [Fact(Timeout = 30_000)] + public async Task WaitForIdle_ThrowsOnceTheSessionIsClosed() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + + session.MarkClosed(); + + _ = await Assert.ThrowsAsync( + () => session.WaitForIdleAsync(TestContext.Current.CancellationToken)); + } + + // agent_activity.py _tts_task_impl: an interruptible say also waits for the user's silence. + [Fact(Timeout = 30_000)] + public async Task AnInterruptibleSay_WaitsForTheUsersSilence() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance, allowInterruptions: true); + Assert.True(handle.TryAuthorizeGeneration()); + session.ClearUserSilence(); + + Task run = SayReply.RunAsync(handle, session, output, Fragment("hello"), handle.TaskCancellationToken); + Assert.Empty(output.Spoken); + + session.MarkUserSilent(); + await run; + Assert.Equal(["hello"], output.Spoken); + } + + [Fact(Timeout = 30_000)] + public async Task ASayThatDisallowsInterruptions_SpeaksWhileTheUserIsSpeaking() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance, allowInterruptions: false); + Assert.True(handle.TryAuthorizeGeneration()); + session.ClearUserSilence(); + + await SayReply.RunAsync(handle, session, output, Fragment("hello"), handle.TaskCancellationToken); + + Assert.Equal(["hello"], output.Spoken); + } + + // agent_activity.py _tts_task_impl: "thinking" when a speech still waits on its tools, else "listening". + [Fact(Timeout = 30_000)] + public async Task ASayThatEndsWhileASpeechWaitsOnItsTools_LeavesTheAgentThinking() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + session.Scheduler.AddBackgroundSpeech(SpeechHandle.Create(_time, NullLogger.Instance)); + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + Assert.True(handle.TryAuthorizeGeneration()); + + await SayReply.RunAsync(handle, session, output, Fragment("one moment"), handle.TaskCancellationToken); + + Assert.Equal(AgentState.Thinking, session.AgentState); + } + + // agent_activity.py _tts_task_impl: the state changes back only if the agent is still "speaking". + [Fact(Timeout = 30_000)] + public async Task ASayThatSpokeNothing_LeavesTheAgentStateAlone() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + session.SetAgentState(AgentState.Thinking); + SpeechHandle handle = SpeechHandle.Create(_time, NullLogger.Instance); + Assert.True(handle.TryAuthorizeGeneration()); + + await SayReply.RunAsync(handle, session, output, NoFragments(), handle.TaskCancellationToken); + + Assert.Equal(AgentState.Thinking, session.AgentState); + } + + // agent_activity.py:3089-3095 (_on_pipeline_reply_done): a say that ends without speaking hands the floor back. + [Fact(Timeout = 30_000)] + public async Task ASayInterruptedBeforeItSpoke_HandsTheFloorBackToListening() + { + VoiceSession session = new(new FakeConversationOutput(), _time, NullLogger.Instance); + session.SetAgentState(AgentState.Thinking); + session.ClearUserSilence(); + + SpeechHandle handle = session.Say("one moment"); + Task afterDone = AfterDoneCallbacks(handle); + await Poll.UntilAsync(() => session.Scheduler.CurrentSpeech == handle); + _ = handle.Interrupt(); + await afterDone; + + Assert.Equal(AgentState.Listening, session.AgentState); + } + + // agent_activity.py:3086-3090 (_no_pending_speech): the floor stays taken while another speech is queued. + [Fact(Timeout = 30_000)] + public async Task ASayEndingWithAnotherQueued_KeepsTheAgentState() + { + FakeConversationOutput output = new(); + VoiceSession session = new(output, _time, NullLogger.Instance); + session.SetAgentState(AgentState.Thinking); + session.ClearUserSilence(); + + SpeechHandle first = session.Say("one moment"); + SpeechHandle second = session.Say("still here"); + Task afterDone = AfterDoneCallbacks(first); + await Poll.UntilAsync(() => session.Scheduler.CurrentSpeech == first); + _ = first.Interrupt(); + await afterDone; + + Assert.Equal(AgentState.Thinking, session.AgentState); + + session.MarkUserSilent(); + _ = await second; + Assert.Equal(["still here"], output.Spoken); + } + + // agent_activity.py:3091-3095: a speech still waiting on its tools keeps the agent thinking (#6904). + [Fact(Timeout = 30_000)] + public async Task ASayEndingWhileAToolRuns_LeavesTheAgentThinking() + { + VoiceSession session = new(new FakeConversationOutput(), _time, NullLogger.Instance); + session.Scheduler.AddBackgroundSpeech(SpeechHandle.Create(_time, NullLogger.Instance)); + session.SetAgentState(AgentState.Thinking); + session.ClearUserSilence(); + + SpeechHandle handle = session.Say("one moment"); + Task afterDone = AfterDoneCallbacks(handle); + await Poll.UntilAsync(() => session.Scheduler.CurrentSpeech == handle); + _ = handle.Interrupt(); + await afterDone; + + Assert.Equal(AgentState.Thinking, session.AgentState); + } + + private static async IAsyncEnumerable NoFragments() + { + await Task.CompletedTask; + yield break; + } + + /// Completes once every done callback registered on before this one has run: + /// they run in registration order, on one thread-pool item. + private static Task AfterDoneCallbacks(SpeechHandle handle) + { + TaskCompletionSource ran = new(TaskCreationOptions.RunContinuationsAsynchronously); + handle.AddDoneCallback(_ => ran.TrySetResult()); + return ran.Task; + } + + private static async IAsyncEnumerable Fragment(string text) + { + yield return text; + } + } +} diff --git a/tests/AgentCore.Domain.Tests/Audit/AuditEventSamples.cs b/tests/AgentCore.Domain.Tests/Audit/AuditEventSamples.cs new file mode 100644 index 00000000..5b5f5266 --- /dev/null +++ b/tests/AgentCore.Domain.Tests/Audit/AuditEventSamples.cs @@ -0,0 +1,83 @@ +using AgentCore.Domain.Audit; + +namespace AgentCore.Domain.Tests.Audit +{ + /// The events of one conversation that the vocabulary tests validate. + internal static class AuditEventSamples + { + /// What the model produced on the turn these facts describe. + internal const string Spoken = "Welcome to Sole, how can I help you today?"; + + /// What the caller heard of it before speaking over the rest. + internal const string Heard = "Welcome to Sole, how can I"; + + internal static readonly DateTimeOffset Start = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); + + internal static AuditEvent Turn(Guid eventId, int turnIndex) + { + return new() + { + ConversationId = "conversation-1", + EventId = eventId, + Kind = AuditEventKind.TurnCompleted, + OccurredAt = Start, + TurnIndex = turnIndex, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(Spoken).Value, + [AuditPayloadKeys.StageBefore] = "greeting", + [AuditPayloadKeys.StageAfter] = "identify", + }, + }; + } + + internal static AuditEvent Interruption(Guid eventId, Guid amends, int turnIndex) + { + return new() + { + ConversationId = "conversation-1", + EventId = eventId, + Kind = AuditEventKind.ReplyInterrupted, + OccurredAt = Start.AddMilliseconds(1_820), + TurnIndex = turnIndex, + AmendsEventId = amends, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(Heard).Value, + [AuditPayloadKeys.DurationUntilInterruptMs] = "1820", + }, + }; + } + + internal static AuditEvent FlaggedPrompt(Guid eventId, int turnIndex, string categories = "harassment") + { + return new() + { + ConversationId = "conversation-1", + EventId = eventId, + Kind = AuditEventKind.PromptFlagged, + OccurredAt = Start.AddMilliseconds(2_400), + TurnIndex = turnIndex, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ModerationCategories] = categories, + }, + }; + } + + internal static AuditEvent Ended() + { + return new() + { + ConversationId = "conversation-1", + EventId = Guid.CreateVersion7(), + Kind = AuditEventKind.ConversationEnded, + OccurredAt = Start.AddMilliseconds(9_000), + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.EndReason] = ConversationEndReasons.ToToken(ConversationEndReason.CallerHungUp), + }, + }; + } + } +} diff --git a/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs b/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs new file mode 100644 index 00000000..c5ae7dc8 --- /dev/null +++ b/tests/AgentCore.Domain.Tests/Audit/AuditEventValidationTests.cs @@ -0,0 +1,187 @@ +using AgentCore.Domain.Audit; +using Xunit; +using static AgentCore.Domain.Tests.Audit.AuditEventSamples; + +namespace AgentCore.Domain.Tests.Audit +{ + /// + /// The chain refuses an event it may not hold: an amendment that names nothing or itself, a hash that is not one, + /// a missing identity, or a conversation end with no reason from the closed set. + /// + public sealed class AuditEventValidationTests + { + /// The reason is counted, so the chain refuses free text under it. + [Fact] + public void AConversationEndedEventWithAFreeTextReason_IsRefused() + { + AuditEvent free = Ended() with + { + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.EndReason] = "the caller hung up", + }, + }; + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(free)); + + Assert.Contains(AuditPayloadKeys.EndReason, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AConversationEndedEventWithoutAReason_IsRefused() + { + AuditEvent silent = Ended() with + { + Payload = new Dictionary(StringComparer.Ordinal), + }; + + _ = Assert.Throws(() => AuditEventVocabulary.Validate(silent)); + } + + /// + /// T23: the table is append-only, so a barge-in is a second event that references the first. + /// + [Fact] + public void AnAmendment_ReferencesTheEventItAmends() + { + AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 2); + AuditEvent interruption = Interruption(eventId: Guid.CreateVersion7(), amends: turn.EventId, turnIndex: 2); + + AuditEvent[] run = [turn, interruption]; + + Assert.All(run, AuditEventVocabulary.Validate); + Assert.Equal(turn.EventId, interruption.AmendsEventId); + Assert.Equal(turn.ConversationId, interruption.ConversationId); + Assert.Equal(turn.TurnIndex, interruption.TurnIndex); + + // The first event is untouched. Nothing rewrote the turn, and both events stand. + Assert.Equal(AuditHash.OfText(Spoken).Value, turn.Payload[AuditPayloadKeys.ReplyTextSha256]); + Assert.Null(turn.AmendsEventId); + } + + /// Section 11, item 6a: the event records the text the caller ACTUALLY HEARD. + [Fact] + public void AnInterruption_RecordsWhatTheCallerHeardAndNotWhatTheModelProduced() + { + AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 0); + AuditEvent interruption = Interruption(eventId: Guid.CreateVersion7(), amends: turn.EventId, turnIndex: 0); + + string produced = turn.Payload[AuditPayloadKeys.ReplyTextSha256]; + string heard = interruption.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]; + + // The chain holds proof of the words and never the words, so the reviewer's check is against + // store 1: the amendment proves what the caller heard, and it is not the whole reply. + Assert.NotEqual(produced, heard); + Assert.Equal(AuditHash.OfText(Heard).Value, heard); + Assert.Equal(AuditHash.OfText(Spoken).Value, produced); + Assert.Equal("1820", interruption.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); + } + + [Fact] + public void AnInterruptionThatAmendsNothing_IsRefused() + { + AuditEvent orphan = Interruption(eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7(), turnIndex: 0) + with + { AmendsEventId = null }; + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(orphan)); + + Assert.Contains("T23", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnInterruptionWithoutTheUtterance_IsRefused() + { + AuditEvent silent = Interruption(eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7(), turnIndex: 0) with + { + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.DurationUntilInterruptMs] = "1820", + }, + }; + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(silent)); + + Assert.Contains(AuditPayloadKeys.UtteranceUntilInterruptSha256, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_EmptyHashOnInterrupt_IsRefused() + { + // The chain stores proof of the words and not the words, so the one value that must never + // reach it is a hash that proves nothing. An empty text still hashes to a full digest. + AuditEvent unproven = Interruption(eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7(), turnIndex: 0) with + { + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.UtteranceUntilInterruptSha256] = string.Empty, + [AuditPayloadKeys.DurationUntilInterruptMs] = "1820", + }, + }; + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(unproven)); + + Assert.Contains(AuditPayloadKeys.UtteranceUntilInterruptSha256, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_EmptyHashOnTurnCompleted_IsRefused() + { + AuditEvent unproven = Turn(eventId: Guid.CreateVersion7(), turnIndex: 0) with + { + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = string.Empty, + }, + }; + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(unproven)); + + Assert.Contains(AuditPayloadKeys.ReplyTextSha256, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_RefusesAnEventWithNoIdentity() + { + AuditEvent malformed = Turn(eventId: Guid.Empty, turnIndex: 0); + + ArgumentException error = Assert.Throws(() => AuditEventVocabulary.Validate(malformed)); + + Assert.Contains("identity", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Validate_RefusesAnAmendmentThatNamesItself() + { + Guid id = Guid.CreateVersion7(); + AuditEvent circular = Interruption(eventId: id, amends: id, turnIndex: 2); + + ArgumentException error = Assert.Throws(() => AuditEventVocabulary.Validate(circular)); + + Assert.Contains("names another event", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void AnEventWithoutAConversationId_IsRefused() + { + AuditEvent nameless = Turn(eventId: Guid.CreateVersion7(), turnIndex: 0) with { ConversationId = string.Empty }; + + _ = Assert.Throws(() => AuditEventVocabulary.Validate(nameless)); + } + + [Fact] + public void AHash_RefusesEverySpellingButLowercaseHexadecimal() + { + // PostgreSQL renders sha256() through encode(..., 'hex'), which is lowercase. + _ = Assert.Throws(() => AuditHash.Parse(new string('A', AuditHash.Length))); + _ = Assert.Throws(() => AuditHash.Parse(new string('0', AuditHash.Length - 1))); + _ = Assert.Throws(() => AuditHash.Parse("not a hash")); + Assert.False(AuditHash.TryParse(null, out _)); + } + } +} diff --git a/tests/AgentCore.Domain.Tests/Audit/AuditEventVocabularyTests.cs b/tests/AgentCore.Domain.Tests/Audit/AuditEventVocabularyTests.cs deleted file mode 100644 index 8256a7ea..00000000 --- a/tests/AgentCore.Domain.Tests/Audit/AuditEventVocabularyTests.cs +++ /dev/null @@ -1,483 +0,0 @@ -using AgentCore.Domain.Audit; -using Xunit; - -namespace AgentCore.Domain.Tests.Audit -{ - /// - /// Both vocabularies are closed, their tokens are stable, and an amendment names the event it - /// amends. - /// - public sealed class AuditEventVocabularyTests - { - /// What the model produced on the turn these facts describe. - private const string Spoken = "Welcome to Sole, how can I help you today?"; - - /// What the caller heard of it before speaking over the rest. - private const string Heard = "Welcome to Sole, how can I"; - - private static readonly DateTimeOffset Start = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); - - [Theory] - [InlineData(AuditEventKind.ConversationStarted, "conversation.started")] - [InlineData(AuditEventKind.TurnCompleted, "turn.completed")] - [InlineData(AuditEventKind.ReplyInterrupted, "reply.interrupted")] - [InlineData(AuditEventKind.ToolFailed, "tool.failed")] - [InlineData(AuditEventKind.PromptFlagged, "prompt.flagged")] - [InlineData(AuditEventKind.ConversationEnded, "conversation.ended")] - public void EachKind_HasItsToken(AuditEventKind kind, string token) - { - // The token is stable forever. A C# rename must not change a hash PostgreSQL already stored. - Assert.Equal(token, AuditEventKinds.ToToken(kind)); - Assert.True(AuditEventKinds.TryParse(token, out AuditEventKind parsed)); - Assert.Equal(kind, parsed); - } - - [Fact] - public void TheVocabulary_IsClosed() - { - AuditEventKind[] declared = Enum.GetValues(); - - // Seven kinds, and the numbers run 0 to 7 with 4 missing. Value 4 was reply.flagged, retired - // on 2026-08-13 when reply moderation was withdrawn. A number is never reused, so a stored - // row can never come to mean something else. - Assert.Equal(7, declared.Length); - Assert.DoesNotContain(declared, kind => (int)kind == 4); - foreach (AuditEventKind kind in declared) - { - Assert.NotEmpty(AuditEventKinds.ToToken(kind)); - } - - _ = Assert.Throws(() => AuditEventKinds.ToToken((AuditEventKind)99)); - Assert.False(AuditEventKinds.TryParse("conversation.transferred", out _)); - } - - [Theory] - [InlineData(ConversationEndReason.CallerHungUp, "caller.hangup")] - [InlineData(ConversationEndReason.AgentCompleted, "agent.completed")] - [InlineData(ConversationEndReason.TransferredToHuman, "agent.transferred")] - [InlineData(ConversationEndReason.Faulted, "conversation.faulted")] - public void EachEndReason_HasItsToken(ConversationEndReason reason, string token) - { - // The token is stable forever, for the reason a kind token is. A report counts these years - // after the conversation, and the count breaks when a token moves. - Assert.Equal(token, ConversationEndReasons.ToToken(reason)); - Assert.True(ConversationEndReasons.TryParse(token, out ConversationEndReason parsed)); - Assert.Equal(reason, parsed); - } - - [Fact] - public void TheEndReasons_AreClosed() - { - ConversationEndReason[] declared = Enum.GetValues(); - - // Section 4 names four ways a conversation ends, and the set holds those four and no more. - Assert.Equal(4, declared.Length); - foreach (ConversationEndReason reason in declared) - { - Assert.NotEmpty(ConversationEndReasons.ToToken(reason)); - } - - _ = Assert.Throws(() => ConversationEndReasons.ToToken((ConversationEndReason)99)); - Assert.False(ConversationEndReasons.TryParse("hangup", out _)); - Assert.False(ConversationEndReasons.TryParse("caller hung up", out _)); - Assert.False(ConversationEndReasons.TryParse("CallerHungUp", out _)); - Assert.False(ConversationEndReasons.TryParse("0", out _)); - } - - [Theory] - [InlineData(ToolFailureKind.Undeclared, "tool.undeclared")] - [InlineData(ToolFailureKind.Faulted, "tool.faulted")] - public void EachToolFailureKind_HasItsToken(ToolFailureKind kind, string token) - { - // The token is stable forever, for the reason an end reason's is. A report that counts how - // often the model invented a tool name reads this token years after the conversation. - Assert.Equal(token, ToolFailureKinds.ToToken(kind)); - Assert.True(ToolFailureKinds.TryParse(token, out ToolFailureKind parsed)); - Assert.Equal(kind, parsed); - } - - [Fact] - public void TheToolFailureKinds_AreClosed() - { - ToolFailureKind[] declared = Enum.GetValues(); - - // Two facts, and no more: the model named a tool the document does not declare, or a - // declared tool threw. The framework's own status set is wider and it is not ours. - Assert.Equal(2, declared.Length); - foreach (ToolFailureKind kind in declared) - { - Assert.NotEmpty(ToolFailureKinds.ToToken(kind)); - } - - _ = Assert.Throws(() => ToolFailureKinds.ToToken((ToolFailureKind)99)); - Assert.False(ToolFailureKinds.TryParse("NotFound", out _)); - Assert.False(ToolFailureKinds.TryParse("Exception", out _)); - Assert.False(ToolFailureKinds.TryParse("1", out _)); - } - - /// The reason is counted, so the chain refuses free text under it. - [Fact] - public void AConversationEndedEventWithAFreeTextReason_IsRefused() - { - AuditEvent free = Ended() with - { - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.EndReason] = "the caller hung up", - }, - }; - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(free)); - - Assert.Contains(AuditPayloadKeys.EndReason, failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void AConversationEndedEventWithoutAReason_IsRefused() - { - AuditEvent silent = Ended() with - { - Payload = new Dictionary(StringComparer.Ordinal), - }; - - _ = Assert.Throws(() => AuditEventVocabulary.Validate(silent)); - } - - /// - /// T23: the table is append-only, so a barge-in is a second event that references the first. - /// - [Fact] - public void AnAmendment_ReferencesTheEventItAmends() - { - AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 2); - AuditEvent interruption = Interruption(eventId: Guid.CreateVersion7(), amends: turn.EventId, turnIndex: 2); - - AuditEvent[] run = [turn, interruption]; - - Assert.All(run, AuditEventVocabulary.Validate); - Assert.Equal(turn.EventId, interruption.AmendsEventId); - Assert.Equal(turn.ConversationId, interruption.ConversationId); - Assert.Equal(turn.TurnIndex, interruption.TurnIndex); - - // The first event is untouched. Nothing rewrote the turn, and both events stand. - Assert.Equal(AuditHash.OfText(Spoken).Value, turn.Payload[AuditPayloadKeys.ReplyTextSha256]); - Assert.Null(turn.AmendsEventId); - } - - /// Section 11, item 6a: the event records the text the caller ACTUALLY HEARD. - [Fact] - public void AnInterruption_RecordsWhatTheCallerHeardAndNotWhatTheModelProduced() - { - AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 0); - AuditEvent interruption = Interruption(eventId: Guid.CreateVersion7(), amends: turn.EventId, turnIndex: 0); - - string produced = turn.Payload[AuditPayloadKeys.ReplyTextSha256]; - string heard = interruption.Payload[AuditPayloadKeys.UtteranceUntilInterruptSha256]; - - // The chain holds proof of the words and never the words, so the reviewer's check is against - // store 1: the amendment proves what the caller heard, and it is not the whole reply. - Assert.NotEqual(produced, heard); - Assert.Equal(AuditHash.OfText(Heard).Value, heard); - Assert.Equal(AuditHash.OfText(Spoken).Value, produced); - Assert.Equal("1820", interruption.Payload[AuditPayloadKeys.DurationUntilInterruptMs]); - } - - [Fact] - public void AnInterruptionThatAmendsNothing_IsRefused() - { - AuditEvent orphan = Interruption(eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7(), turnIndex: 0) - with - { AmendsEventId = null }; - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(orphan)); - - Assert.Contains("T23", failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void AnInterruptionWithoutTheUtterance_IsRefused() - { - AuditEvent silent = Interruption(eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7(), turnIndex: 0) with - { - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.DurationUntilInterruptMs] = "1820", - }, - }; - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(silent)); - - Assert.Contains(AuditPayloadKeys.UtteranceUntilInterruptSha256, failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void Validate_EmptyHashOnInterrupt_IsRefused() - { - // The chain stores proof of the words and not the words, so the one value that must never - // reach it is a hash that proves nothing. An empty text still hashes to a full digest. - AuditEvent unproven = Interruption(eventId: Guid.CreateVersion7(), amends: Guid.CreateVersion7(), turnIndex: 0) with - { - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.UtteranceUntilInterruptSha256] = string.Empty, - [AuditPayloadKeys.DurationUntilInterruptMs] = "1820", - }, - }; - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(unproven)); - - Assert.Contains(AuditPayloadKeys.UtteranceUntilInterruptSha256, failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void Validate_EmptyHashOnTurnCompleted_IsRefused() - { - AuditEvent unproven = Turn(eventId: Guid.CreateVersion7(), turnIndex: 0) with - { - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ReplyTextSha256] = string.Empty, - }, - }; - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(unproven)); - - Assert.Contains(AuditPayloadKeys.ReplyTextSha256, failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void Validate_RefusesAnEventWithNoIdentity() - { - AuditEvent malformed = Turn(eventId: Guid.Empty, turnIndex: 0); - - ArgumentException error = Assert.Throws(() => AuditEventVocabulary.Validate(malformed)); - - Assert.Contains("identity", error.Message, StringComparison.Ordinal); - } - - [Fact] - public void Validate_RefusesAnAmendmentThatNamesItself() - { - Guid id = Guid.CreateVersion7(); - AuditEvent circular = Interruption(eventId: id, amends: id, turnIndex: 2); - - ArgumentException error = Assert.Throws(() => AuditEventVocabulary.Validate(circular)); - - Assert.Contains("names another event", error.Message, StringComparison.Ordinal); - } - - [Fact] - public void Validate_AcceptsAnAmendmentThatNamesAnotherEvent() - { - AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 2); - AuditEvent interruption = - Interruption(eventId: Guid.CreateVersion7(), amends: turn.EventId, turnIndex: 2); - - AuditEventVocabulary.Validate(interruption); - - Assert.Equal(turn.EventId, interruption.AmendsEventId); - } - - [Fact] - public void AnEventWithoutAConversationId_IsRefused() - { - AuditEvent nameless = Turn(eventId: Guid.CreateVersion7(), turnIndex: 0) with { ConversationId = string.Empty }; - - _ = Assert.Throws(() => AuditEventVocabulary.Validate(nameless)); - } - - [Fact] - public void AHash_RefusesEverySpellingButLowercaseHexadecimal() - { - // PostgreSQL renders sha256() through encode(..., 'hex'), which is lowercase. - _ = Assert.Throws(() => AuditHash.Parse(new string('A', AuditHash.Length))); - _ = Assert.Throws(() => AuditHash.Parse(new string('0', AuditHash.Length - 1))); - _ = Assert.Throws(() => AuditHash.Parse("not a hash")); - Assert.False(AuditHash.TryParse(null, out _)); - } - - /// - /// The moderation verdict is known BEFORE the model runs, so the event amends nothing. This is - /// the rule that differs from reply.interrupted, and it is the reason the two kinds are - /// two kinds. - /// - [Fact] - public void AFlaggedPrompt_NeedsNoAmendment() - { - AuditEvent flagged = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1); - - Assert.Null(flagged.AmendsEventId); - Assert.Equal(1, flagged.TurnIndex); - - AuditEvent[] run = [flagged]; - - Assert.All(run, AuditEventVocabulary.Validate); - } - - /// - /// The kind alone says something flagged the caller. The categories are the only other fact the - /// event holds, and §9 makes the chain the only long-term record. - /// - [Fact] - public void AFlaggedPromptWithoutTheCategories_IsRefused() - { - AuditEvent silent = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1) with - { - Payload = new Dictionary(StringComparer.Ordinal), - }; - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(silent)); - - Assert.Contains(AuditPayloadKeys.ModerationCategories, failure.Message, StringComparison.Ordinal); - } - - [Fact] - public void AFlaggedPromptWithAnEmptyCategoryList_IsRefused() - { - AuditEvent empty = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: string.Empty); - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(empty)); - - Assert.Contains(AuditPayloadKeys.ModerationCategories, failure.Message, StringComparison.Ordinal); - } - - /// A reader splits on a comma and counts, and a blank member makes the count wrong. - [Theory] - [InlineData("harassment,,violence")] - [InlineData("harassment,")] - [InlineData(",harassment")] - public void AFlaggedPromptWithABlankCategory_IsRefused(string categories) - { - AuditEvent blank = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: categories); - - ArgumentException failure = Assert.Throws( - () => AuditEventVocabulary.Validate(blank)); - - Assert.Contains(AuditPayloadKeys.ModerationCategories, failure.Message, StringComparison.Ordinal); - } - - /// - /// The category list is stored exactly as the endpoint returned it. Nothing sorts it, and - /// nothing rewrites it. - /// - [Fact] - public void AFlaggedPromptKeepsTheOrderTheEndpointReturned() - { - AuditEvent first = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: "harassment,violence"); - AuditEvent second = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: "violence,harassment"); - - AuditEventVocabulary.Validate(first); - AuditEventVocabulary.Validate(second); - - Assert.Equal("harassment,violence", first.Payload[AuditPayloadKeys.ModerationCategories]); - Assert.Equal("violence,harassment", second.Payload[AuditPayloadKeys.ModerationCategories]); - } - - /// - /// The taxonomy belongs to the moderation endpoint and it is open, unlike - /// . A closed set would make throw on a - /// category OpenAI added, and destroy the record the chain exists to protect. - /// - [Fact] - public void ACategoryTheLibraryNeverNamed_IsAccepted() - { - AuditEvent novel = FlaggedPrompt( - eventId: Guid.CreateVersion7(), - turnIndex: 1, - categories: "illicit/violent,some-category-openai-added-last-tuesday"); - - AuditEventVocabulary.Validate(novel); - - Assert.Equal( - "illicit/violent,some-category-openai-added-last-tuesday", - novel.Payload[AuditPayloadKeys.ModerationCategories]); - } - - /// The rule requires no amendment, and it forbids none either. - [Fact] - public void AFlaggedPrompt_MayStillCarryAnAmendment() - { - AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 1); - AuditEvent flagged = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1) with { AmendsEventId = turn.EventId }; - - AuditEvent[] run = [turn, flagged]; - - Assert.All(run, AuditEventVocabulary.Validate); - Assert.Equal(turn.EventId, flagged.AmendsEventId); - } - - private static AuditEvent Turn(Guid eventId, int turnIndex) - { - return new() - { - ConversationId = "conversation-1", - EventId = eventId, - Kind = AuditEventKind.TurnCompleted, - OccurredAt = Start, - TurnIndex = turnIndex, - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(Spoken).Value, - [AuditPayloadKeys.StageBefore] = "greeting", - [AuditPayloadKeys.StageAfter] = "identify", - }, - }; - } - - private static AuditEvent Interruption(Guid eventId, Guid amends, int turnIndex) - { - return new() - { - ConversationId = "conversation-1", - EventId = eventId, - Kind = AuditEventKind.ReplyInterrupted, - OccurredAt = Start.AddMilliseconds(1_820), - TurnIndex = turnIndex, - AmendsEventId = amends, - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(Heard).Value, - [AuditPayloadKeys.DurationUntilInterruptMs] = "1820", - }, - }; - } - - private static AuditEvent FlaggedPrompt(Guid eventId, int turnIndex, string categories = "harassment") - { - return new() - { - ConversationId = "conversation-1", - EventId = eventId, - Kind = AuditEventKind.PromptFlagged, - OccurredAt = Start.AddMilliseconds(2_400), - TurnIndex = turnIndex, - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ModerationCategories] = categories, - }, - }; - } - - private static AuditEvent Ended() - { - return new() - { - ConversationId = "conversation-1", - EventId = Guid.CreateVersion7(), - Kind = AuditEventKind.ConversationEnded, - OccurredAt = Start.AddMilliseconds(9_000), - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.EndReason] = ConversationEndReasons.ToToken(ConversationEndReason.CallerHungUp), - }, - }; - } - } -} diff --git a/tests/AgentCore.Domain.Tests/Audit/AuditVocabularyTokenTests.cs b/tests/AgentCore.Domain.Tests/Audit/AuditVocabularyTokenTests.cs new file mode 100644 index 00000000..0d0b20c2 --- /dev/null +++ b/tests/AgentCore.Domain.Tests/Audit/AuditVocabularyTokenTests.cs @@ -0,0 +1,110 @@ +using AgentCore.Domain.Audit; +using Xunit; + +namespace AgentCore.Domain.Tests.Audit +{ + /// + /// Every closed vocabulary of the chain names each value by one stable token, and refuses any other. + /// + public sealed class AuditVocabularyTokenTests + { + [Theory] + [InlineData(AuditEventKind.ConversationStarted, "conversation.started")] + [InlineData(AuditEventKind.TurnCompleted, "turn.completed")] + [InlineData(AuditEventKind.ReplyInterrupted, "reply.interrupted")] + [InlineData(AuditEventKind.ToolFailed, "tool.failed")] + [InlineData(AuditEventKind.PromptFlagged, "prompt.flagged")] + [InlineData(AuditEventKind.ConversationEnded, "conversation.ended")] + [InlineData(AuditEventKind.TurnRefused, "turn.refused")] + public void EachKind_HasItsToken(AuditEventKind kind, string token) + { + // The token is stable forever. A C# rename must not change a hash PostgreSQL already stored. + Assert.Equal(token, AuditEventKinds.ToToken(kind)); + Assert.True(AuditEventKinds.TryParse(token, out AuditEventKind parsed)); + Assert.Equal(kind, parsed); + } + + [Fact] + public void TheVocabulary_IsClosed() + { + AuditEventKind[] declared = Enum.GetValues(); + + // Eight kinds, and the numbers run 0 to 8 with 4 missing. Value 4 was reply.flagged, retired + // on 2026-08-13 when reply moderation was withdrawn. A number is never reused, so a stored + // row can never come to mean something else. + Assert.Equal(8, declared.Length); + Assert.DoesNotContain(declared, kind => (int)kind == 4); + foreach (AuditEventKind kind in declared) + { + Assert.NotEmpty(AuditEventKinds.ToToken(kind)); + } + + _ = Assert.Throws(() => AuditEventKinds.ToToken((AuditEventKind)99)); + Assert.False(AuditEventKinds.TryParse("conversation.transferred", out _)); + } + + [Theory] + [InlineData(ConversationEndReason.CallerHungUp, "caller.hangup")] + [InlineData(ConversationEndReason.AgentCompleted, "agent.completed")] + [InlineData(ConversationEndReason.TransferredToHuman, "agent.transferred")] + [InlineData(ConversationEndReason.Faulted, "conversation.faulted")] + public void EachEndReason_HasItsToken(ConversationEndReason reason, string token) + { + // The token is stable forever, for the reason a kind token is. A report counts these years + // after the conversation, and the count breaks when a token moves. + Assert.Equal(token, ConversationEndReasons.ToToken(reason)); + Assert.True(ConversationEndReasons.TryParse(token, out ConversationEndReason parsed)); + Assert.Equal(reason, parsed); + } + + [Fact] + public void TheEndReasons_AreClosed() + { + ConversationEndReason[] declared = Enum.GetValues(); + + // Section 4 names four ways a conversation ends, and the set holds those four and no more. + Assert.Equal(4, declared.Length); + foreach (ConversationEndReason reason in declared) + { + Assert.NotEmpty(ConversationEndReasons.ToToken(reason)); + } + + _ = Assert.Throws(() => ConversationEndReasons.ToToken((ConversationEndReason)99)); + Assert.False(ConversationEndReasons.TryParse("hangup", out _)); + Assert.False(ConversationEndReasons.TryParse("caller hung up", out _)); + Assert.False(ConversationEndReasons.TryParse("CallerHungUp", out _)); + Assert.False(ConversationEndReasons.TryParse("0", out _)); + } + + [Theory] + [InlineData(ToolFailureKind.Undeclared, "tool.undeclared")] + [InlineData(ToolFailureKind.Faulted, "tool.faulted")] + public void EachToolFailureKind_HasItsToken(ToolFailureKind kind, string token) + { + // The token is stable forever, for the reason an end reason's is. A report that counts how + // often the model invented a tool name reads this token years after the conversation. + Assert.Equal(token, ToolFailureKinds.ToToken(kind)); + Assert.True(ToolFailureKinds.TryParse(token, out ToolFailureKind parsed)); + Assert.Equal(kind, parsed); + } + + [Fact] + public void TheToolFailureKinds_AreClosed() + { + ToolFailureKind[] declared = Enum.GetValues(); + + // Two facts, and no more: the model named a tool the document does not declare, or a + // declared tool threw. The framework's own status set is wider and it is not ours. + Assert.Equal(2, declared.Length); + foreach (ToolFailureKind kind in declared) + { + Assert.NotEmpty(ToolFailureKinds.ToToken(kind)); + } + + _ = Assert.Throws(() => ToolFailureKinds.ToToken((ToolFailureKind)99)); + Assert.False(ToolFailureKinds.TryParse("NotFound", out _)); + Assert.False(ToolFailureKinds.TryParse("Exception", out _)); + Assert.False(ToolFailureKinds.TryParse("1", out _)); + } + } +} diff --git a/tests/AgentCore.Domain.Tests/Audit/PromptFlaggedValidationTests.cs b/tests/AgentCore.Domain.Tests/Audit/PromptFlaggedValidationTests.cs new file mode 100644 index 00000000..297243a9 --- /dev/null +++ b/tests/AgentCore.Domain.Tests/Audit/PromptFlaggedValidationTests.cs @@ -0,0 +1,124 @@ +using AgentCore.Domain.Audit; +using Xunit; +using static AgentCore.Domain.Tests.Audit.AuditEventSamples; + +namespace AgentCore.Domain.Tests.Audit +{ + /// + /// A prompt.flagged event carries the endpoint's categories as it returned them, and amends nothing. + /// + public sealed class PromptFlaggedValidationTests + { + /// + /// The moderation verdict is known BEFORE the model runs, so the event amends nothing. This is + /// the rule that differs from reply.interrupted, and it is the reason the two kinds are + /// two kinds. + /// + [Fact] + public void AFlaggedPrompt_NeedsNoAmendment() + { + AuditEvent flagged = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1); + + Assert.Null(flagged.AmendsEventId); + Assert.Equal(1, flagged.TurnIndex); + + AuditEvent[] run = [flagged]; + + Assert.All(run, AuditEventVocabulary.Validate); + } + + /// + /// The kind alone says something flagged the caller. The categories are the only other fact the + /// event holds, and §9 makes the chain the only long-term record. + /// + [Fact] + public void AFlaggedPromptWithoutTheCategories_IsRefused() + { + AuditEvent silent = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1) with + { + Payload = new Dictionary(StringComparer.Ordinal), + }; + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(silent)); + + Assert.Contains(AuditPayloadKeys.ModerationCategories, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AFlaggedPromptWithAnEmptyCategoryList_IsRefused() + { + AuditEvent empty = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: string.Empty); + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(empty)); + + Assert.Contains(AuditPayloadKeys.ModerationCategories, failure.Message, StringComparison.Ordinal); + } + + /// A reader splits on a comma and counts, and a blank member makes the count wrong. + [Theory] + [InlineData("harassment,,violence")] + [InlineData("harassment,")] + [InlineData(",harassment")] + public void AFlaggedPromptWithABlankCategory_IsRefused(string categories) + { + AuditEvent blank = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: categories); + + ArgumentException failure = Assert.Throws( + () => AuditEventVocabulary.Validate(blank)); + + Assert.Contains(AuditPayloadKeys.ModerationCategories, failure.Message, StringComparison.Ordinal); + } + + /// + /// The category list is stored exactly as the endpoint returned it. Nothing sorts it, and + /// nothing rewrites it. + /// + [Fact] + public void AFlaggedPromptKeepsTheOrderTheEndpointReturned() + { + AuditEvent first = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: "harassment,violence"); + AuditEvent second = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1, categories: "violence,harassment"); + + AuditEventVocabulary.Validate(first); + AuditEventVocabulary.Validate(second); + + Assert.Equal("harassment,violence", first.Payload[AuditPayloadKeys.ModerationCategories]); + Assert.Equal("violence,harassment", second.Payload[AuditPayloadKeys.ModerationCategories]); + } + + /// + /// The taxonomy belongs to the moderation endpoint and it is open, unlike + /// . A closed set would make throw on a + /// category OpenAI added, and destroy the record the chain exists to protect. + /// + [Fact] + public void ACategoryTheLibraryNeverNamed_IsAccepted() + { + AuditEvent novel = FlaggedPrompt( + eventId: Guid.CreateVersion7(), + turnIndex: 1, + categories: "illicit/violent,some-category-openai-added-last-tuesday"); + + AuditEventVocabulary.Validate(novel); + + Assert.Equal( + "illicit/violent,some-category-openai-added-last-tuesday", + novel.Payload[AuditPayloadKeys.ModerationCategories]); + } + + /// The rule requires no amendment, and it forbids none either. + [Fact] + public void AFlaggedPrompt_MayStillCarryAnAmendment() + { + AuditEvent turn = Turn(eventId: Guid.CreateVersion7(), turnIndex: 1); + AuditEvent flagged = FlaggedPrompt(eventId: Guid.CreateVersion7(), turnIndex: 1) with { AmendsEventId = turn.EventId }; + + AuditEvent[] run = [turn, flagged]; + + Assert.All(run, AuditEventVocabulary.Validate); + Assert.Equal(turn.EventId, flagged.AmendsEventId); + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/LostReplyAuditSink.cs b/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/LostReplyAuditSink.cs new file mode 100644 index 00000000..da9b6776 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/LostReplyAuditSink.cs @@ -0,0 +1,35 @@ +using AgentCore.Application.Ports; +using AgentCore.Domain.Audit; +using Npgsql; + +namespace AgentCore.Infrastructure.Tests.Audit.Postgres +{ + /// + /// A store whose first batch commits and then loses its reply, the way a connection that drops after + /// COMMIT does. + /// + internal sealed class LostReplyAuditSink(IAuditSinkPort inner) : IAuditSinkPort + { + private readonly TaskCompletionSource _lostReply = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes once the first batch has committed and its reply was lost. + public Task LostReply => _lostReply.Task; + + public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) + { + return AppendManyAsync([auditEvent], cancellationToken); + } + + public async ValueTask AppendManyAsync( + IReadOnlyList auditEvents, + CancellationToken cancellationToken = default) + { + await inner.AppendManyAsync(auditEvents, cancellationToken); + + if (_lostReply.TrySetResult()) + { + throw new NpgsqlException("The reply to COMMIT was lost.", new IOException("The connection dropped.")); + } + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/QueuedPostgresAuditSinkTests.cs b/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/QueuedPostgresAuditSinkTests.cs new file mode 100644 index 00000000..4c76832b --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Audit/Postgres/QueuedPostgresAuditSinkTests.cs @@ -0,0 +1,72 @@ +using AgentCore.Application.Audit; +using AgentCore.Domain.Audit; +using AgentCore.Infrastructure.Audit.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using AgentCore.TestSupport; +using Npgsql; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Audit.Postgres +{ + /// + /// The audit queue retrying into the PostgreSQL store. + /// + public sealed class QueuedPostgresAuditSinkTests : PostgresDatabaseTest + { + /// + protected override bool Migrated => true; + + [PostgresFact] + public async Task ABatchThatCommittedButReportedATransientFault_IsRetriedAndWrittenOnce_AndTheChainStaysDense() + { + FakeTimeProvider time = new(DateTimeOffset.UnixEpoch); + LostReplyAuditSink store = new(new PostgresAuditSink(DataSource)); + await using QueuedAuditSink sink = new(store, timeProvider: time); + AuditEvent first = Event(); + AuditEvent second = Event(); + AuditEvent later = Event(); + + await sink.AppendAsync(first, Token); + await sink.AppendAsync(second, Token); + await store.LostReply.WaitAsync(TimeSpan.FromSeconds(10), Token); + await time.WaitForTimersAsync(1).WaitAsync(TimeSpan.FromSeconds(10), Token); + await sink.AppendAsync(later, Token); + + time.Advance(TimeSpan.FromMinutes(1)); + await sink.FlushAsync(Token); + + // The replay reached a table that already held the first two rows. The (conversation_id, event_id) + // key and the NOT EXISTS filter wrote nothing twice and left no hole in the sequence. + Assert.Equal( + [(first.EventId, 0L), (second.EventId, 1L), (later.EventId, 2L)], + await RowsOfAsync("C1")); + } + + private static AuditEvent Event() + { + return new() + { + ConversationId = "C1", + EventId = Guid.CreateVersion7(), + Kind = AuditEventKind.ConversationStarted, + OccurredAt = DateTimeOffset.UnixEpoch, + }; + } + + private async Task> RowsOfAsync(string conversationId) + { + await using NpgsqlCommand command = DataSource.CreateCommand( + "SELECT event_id, sequence FROM agentcore.audit_event WHERE conversation_id = $1 ORDER BY sequence"); + _ = command.Parameters.Add(new NpgsqlParameter { Value = conversationId }); + + List<(Guid, long)> rows = []; + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(Token); + while (await reader.ReadAsync(Token)) + { + rows.Add((reader.GetGuid(0), reader.GetInt64(1))); + } + + return rows; + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/AnnouncingToolChatClient.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/AnnouncingToolChatClient.cs new file mode 100644 index 00000000..74d4287a --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/AnnouncingToolChatClient.cs @@ -0,0 +1,75 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// Says "Checking n. " and calls the first tool on the same message, round n. Once a tool answered, + /// it speaks word by word instead, unless is + /// : then it calls the tool again every round. + /// + internal sealed class AnnouncingToolChatClient(string? answer) : IChatClient + { + private int _rounds; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List request = [.. messages]; + await Task.Yield(); + int round = Interlocked.Increment(ref _rounds); + string id = Guid.NewGuid().ToString("N"); + + AIFunction? tool = options?.Tools?.OfType().FirstOrDefault(); + if (tool is null || (answer is not null && request.Count > 0 && request[^1].Role == ChatRole.Tool)) + { + string[] words = (answer ?? string.Empty).Split(' '); + for (int index = 0; index < words.Length; index++) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, index == 0 ? words[index] : " " + words[index]) + { + ResponseId = id, + MessageId = id, + }; + } + + yield break; + } + + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new TextContent($"Checking {round}. "), + new FunctionCallContent($"call_{round}", tool.Name, new Dictionary(StringComparer.Ordinal)), + ]) + { + ResponseId = id, + MessageId = id, + }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/BackgroundStatusChatClient.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/BackgroundStatusChatClient.cs new file mode 100644 index 00000000..f2661741 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/BackgroundStatusChatClient.cs @@ -0,0 +1,79 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// A parent model that starts one background: task when the caller says go, asks for every task's + /// status when the caller says status?, and otherwise answers in words. It keeps what each status call returned. + /// + internal sealed class BackgroundStatusChatClient : IChatClient + { + /// Gets what each status call returned, in call order. + public List Statuses { get; } = []; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + List request = [.. messages]; + await Task.Yield(); + string id = Guid.NewGuid().ToString("N"); + + int asked = request.FindLastIndex(message => message.Role == ChatRole.User); + FunctionResultContent? answered = request.Skip(asked + 1).SelectMany(message => message.Contents).OfType().LastOrDefault(); + if (answered is not null) + { + if (answered.CallId.StartsWith("status_", StringComparison.Ordinal)) + { + lock (Statuses) + { + Statuses.Add(answered.Result?.ToString() ?? string.Empty); + } + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, "ok") { ResponseId = id, MessageId = id }; + yield break; + } + + FunctionCallContent? call = (asked < 0 ? null : request[asked].Text) switch + { + "go" => new FunctionCallContent("start_" + id, "background_agents_start_task", new Dictionary(StringComparer.Ordinal) + { + ["agentName"] = "blocker", + ["input"] = "work", + ["description"] = "d", + }), + "status?" => new FunctionCallContent("status_" + id, "background_agents_get_all_tasks", new Dictionary(StringComparer.Ordinal)), + _ => null, + }; + + yield return call is null + ? new ChatResponseUpdate(ChatRole.Assistant, "ok") { ResponseId = id, MessageId = id } + : new ChatResponseUpdate(ChatRole.Assistant, [call]) { ResponseId = id, MessageId = id }; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ContinuationStoreTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ContinuationStoreTests.cs index 8cca20ac..5d95712d 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ContinuationStoreTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/ContinuationStoreTests.cs @@ -1,11 +1,10 @@ -using System.Text.Json; using AgentCore.Infrastructure.Conversation.Postgres; using AgentCore.Infrastructure.Tests.Database.Postgres; using Xunit; namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { - /// The continuation map beside the conversations: one envelope per opaque id. + /// The continuation rows beside the conversations: one row per response id. public sealed class ContinuationStoreTests : PostgresDatabaseTest { /// @@ -26,44 +25,50 @@ SELECT count(*) FROM information_schema.tables } [PostgresFact] - public async Task RoundTrip_SaveGetDelete() + public async Task RoundTrip_SaveFindDelete() { // Arrange PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("conv_1", Token); - using JsonDocument document = JsonDocument.Parse("""{ "conversationId": "c1", "state": {} }"""); // Act - await store.SaveContinuationAsync("conv_1", "conv_1", document.RootElement, Token); - JsonElement? found = await store.GetContinuationAsync("conv_1", Token); + await store.SaveContinuationAsync("resp_1", "conv_1", Token); + string? found = await store.FindContinuationAsync("resp_1", Token); // Assert - _ = Assert.NotNull(found); - Assert.True(JsonElement.DeepEquals(document.RootElement, found.Value)); + Assert.Equal("conv_1", found); // Act - using JsonDocument replacement = JsonDocument.Parse("{}"); - await store.SaveContinuationAsync("conv_1", "conv_1", replacement.RootElement, Token); - JsonElement? replaced = await store.GetContinuationAsync("conv_1", Token); + await store.DeleteContinuationAsync("resp_1", Token); // Assert - Assert.Equal("{}", replaced?.GetRawText()); + Assert.Null(await store.FindContinuationAsync("resp_1", Token)); + } + + [PostgresFact] + public async Task SaveContinuation_TheSameResponseIdTwice_KeepsTheFirstConversation() + { + // Arrange — a response id is written once, in its turn's own commit, and never rewritten. + PostgresConversationStore store = new(DataSource); + _ = await store.CreateAsync("conv_1", Token); + _ = await store.CreateAsync("conv_2", Token); + await store.SaveContinuationAsync("resp_1", "conv_1", Token); // Act - await store.DeleteContinuationAsync("conv_1", Token); + await store.SaveContinuationAsync("resp_1", "conv_2", Token); // Assert - Assert.Null(await store.GetContinuationAsync("conv_1", Token)); + Assert.Equal("conv_1", await store.FindContinuationAsync("resp_1", Token)); } [PostgresFact] - public async Task GetContinuation_UnknownId_AnswersNull() + public async Task FindContinuation_UnknownId_AnswersNull() { // Arrange PostgresConversationStore store = new(DataSource); // Act - JsonElement? found = await store.GetContinuationAsync("conv_missing", Token); + string? found = await store.FindContinuationAsync("resp_missing", Token); // Assert Assert.Null(found); diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/HangingChildChatClient.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/HangingChildChatClient.cs new file mode 100644 index 00000000..99a8e001 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/HangingChildChatClient.cs @@ -0,0 +1,46 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// A child model that stays in its call until the token it was handed cancels it. + internal sealed class HangingChildChatClient : IChatClient + { + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes once a request reaches the model. + public Task Started => _started.Task; + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = _started.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + yield break; + } + + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (ChatResponseUpdate update in GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToChatResponse(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public void Dispose() + { + // Nothing to release. + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreAppendTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreAppendTests.cs index 2b6410d0..577014db 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreAppendTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreAppendTests.cs @@ -172,6 +172,25 @@ public async Task RewriteAsync_OneRow_ChangesOnlyThatRowsContent() Assert.Equal(["corrected", "second"], rows.Select(row => row.Content.Text)); } + [PostgresFact] + public async Task DeleteMessageAsync_OneRow_RemovesOnlyThatRow_AndTheOthersKeepTheirOrdinals() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + _ = await store.CreateAsync("c1", Token); + _ = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "zero") { MessageId = "m0" }, Token); + _ = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "one") { MessageId = "m1" }, Token); + _ = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "two") { MessageId = "m2" }, Token); + + // Act + await store.DeleteMessageAsync("c1", "m1", Token); + await store.DeleteMessageAsync("c1", "never-written", Token); + + // Assert + IReadOnlyList rows = await store.ReadAllAsync("c1", Token); + Assert.Equal([(0, "zero"), (2, "two")], rows.Select(row => (row.Ordinal, row.Content.Text))); + } + [PostgresFact] public async Task AppendAsync_AfterATruncate_NeverReusesTheOrdinalsItTook() { diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreBusyMarkTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreBusyMarkTests.cs new file mode 100644 index 00000000..c45967b5 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreBusyMarkTests.cs @@ -0,0 +1,82 @@ +using AgentCore.Application.Ports; +using AgentCore.Infrastructure.Conversation.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// The busy mark a running turn puts on its conversation, in PostgreSQL: one holder at a time, renewed by the same + /// holder, freed by a clear or by its lease lapsing. + /// +#pragma warning disable CA1859 // store is typed as IConversationStore throughout, as every caller holds it. + public sealed class PostgresConversationStoreBusyMarkTests : PostgresDatabaseTest + { + private static readonly TimeSpan Lease = TimeSpan.FromMinutes(1); + + /// + protected override bool Migrated => true; + + [PostgresFact] + public async Task TryMarkBusyAsync_ALiveMarkOfAnotherHolder_IsRefused_AndItsHolderRenewsIt() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + + // Act + bool first = await store.TryMarkBusyAsync("c1", "host-a", Lease, Token); + bool other = await store.TryMarkBusyAsync("c1", "host-b", Lease, Token); + bool renewed = await store.TryMarkBusyAsync("c1", "host-a", Lease, Token); + + // Assert + Assert.Equal((true, false, true), (first, other, renewed)); + Assert.Equal("host-a", await ScalarAsync("SELECT holder FROM agentcore.conversation_busy WHERE conversation_id = 'c1'")); + } + + [PostgresFact] + public async Task ClearBusyAsync_ByAnotherHolder_LeavesTheMark_AndByItsHolder_FreesIt() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + _ = await store.TryMarkBusyAsync("c1", "host-a", Lease, Token); + + // Act + await store.ClearBusyAsync("c1", "host-b", Token); + bool whileMarked = await store.TryMarkBusyAsync("c1", "host-b", Lease, Token); + await store.ClearBusyAsync("c1", "host-a", Token); + bool afterClear = await store.TryMarkBusyAsync("c1", "host-b", Lease, Token); + + // Assert + Assert.Equal((false, true), (whileMarked, afterClear)); + } + + [PostgresFact] + public async Task TryMarkBusyAsync_AMarkWhoseLeaseLapsed_IsTakenByAnotherHolder() + { + // Arrange: a host that crashed leaves a mark nobody renews, which has lapsed by now. + IConversationStore store = new PostgresConversationStore(DataSource); + _ = await store.TryMarkBusyAsync("c1", "crashed", TimeSpan.FromSeconds(-1), Token); + + // Act + bool taken = await store.TryMarkBusyAsync("c1", "host-b", Lease, Token); + + // Assert + Assert.True(taken); + } + + [PostgresFact] + public async Task TryMarkBusyAsync_AConversationWithNoRowYet_IsMarked_AndGetsNoRow() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + + // Act + bool marked = await store.TryMarkBusyAsync("new", "host-a", Lease, Token); + + // Assert + Assert.True(marked); + Assert.Null(await store.GetAsync("new", Token)); + } + } +#pragma warning restore CA1859 +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreSweepTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreSweepTests.cs index de8b8df1..bb97afe5 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreSweepTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreSweepTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using AgentCore.Application.Transcript; using AgentCore.Infrastructure.Conversation.Postgres; using AgentCore.Infrastructure.Tests.Database.Postgres; @@ -8,7 +7,7 @@ namespace AgentCore.Infrastructure.Tests.Conversation.Postgres { /// - /// Retention, which deletes stale continuation rows only. A conversation and its words are + /// Retention, which deletes stale response ids only. A conversation and its words are /// never swept: they stay whole for as long as the row lives, whether or not anything can /// still resume it. /// @@ -19,37 +18,31 @@ public sealed class PostgresConversationStoreSweepTests : PostgresDatabaseTest /// protected override bool Migrated => true; - private static JsonElement Envelope() - { - using JsonDocument document = JsonDocument.Parse("""{ "state": {} }"""); - return document.RootElement.Clone(); - } - [PostgresFact] - public async Task SweepAsync_AContinuationUntouchedPastTheWindow_TakesTheRow() + public async Task SweepAsync_AResponseIdUntouchedPastTheWindow_TakesTheRow() { // Arrange PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("c1", Token); - await store.SaveContinuationAsync("resp_1", "c1", Envelope(), Token); - await ExecuteAsync("UPDATE agentcore.response_continuation SET updated_at = now() - interval '90 days'"); + await store.SaveContinuationAsync("resp_1", "c1", Token); + await ExecuteAsync("UPDATE agentcore.response_continuation SET created_at = now() - interval '90 days'"); // Act int swept = await store.SweepAsync(Window, cancellationToken: Token); // Assert Assert.Equal(1, swept); - Assert.Null(await store.GetContinuationAsync("resp_1", Token)); + Assert.Null(await store.FindContinuationAsync("resp_1", Token)); } [PostgresFact] - public async Task SweepAsync_AContinuationUntouchedPastTheWindow_LeavesTheConversationAndItsWords() + public async Task SweepAsync_AResponseIdUntouchedPastTheWindow_LeavesTheConversationAndItsWords() { // Arrange PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("c1", Token); - await store.SaveContinuationAsync("c1", "c1", Envelope(), Token); - await ExecuteAsync("UPDATE agentcore.response_continuation SET updated_at = now() - interval '90 days'"); + await store.SaveContinuationAsync("resp_1", "c1", Token); + await ExecuteAsync("UPDATE agentcore.response_continuation SET created_at = now() - interval '90 days'"); // Act _ = await store.SweepAsync(Window, cancellationToken: Token); @@ -61,7 +54,7 @@ public async Task SweepAsync_AContinuationUntouchedPastTheWindow_LeavesTheConver [PostgresFact] public async Task SweepAsync_AConversationRowItself_IsNeverTaken() { - // Arrange — an old conversation with no continuation at all: nothing here for the sweep to find. + // Arrange — an old conversation with no response id at all: nothing here for the sweep to find. PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("old", Token); await ExecuteAsync("UPDATE agentcore.conversation SET created_at = now() - interval '400 days'"); @@ -75,42 +68,44 @@ public async Task SweepAsync_AConversationRowItself_IsNeverTaken() } [PostgresFact] - public async Task SweepAsync_AContinuationWrittenInsideTheWindow_LeavesIt() + public async Task SweepAsync_AResponseIdWrittenInsideTheWindow_LeavesIt() { // Arrange PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("c1", Token); - await store.SaveContinuationAsync("resp_1", "c1", Envelope(), Token); + await store.SaveContinuationAsync("resp_1", "c1", Token); // Act int swept = await store.SweepAsync(Window, cancellationToken: Token); // Assert Assert.Equal(0, swept); - Assert.NotNull(await store.GetContinuationAsync("resp_1", Token)); + Assert.Equal("c1", await store.FindContinuationAsync("resp_1", Token)); } [PostgresFact] - public async Task SweepAsync_ARewrittenContinuation_ResetsItsRetentionClock() + public async Task SweepAsync_OneResponseIdInsideTheWindowAndOneOutsideIt_TakesOnlyTheOlderOne() { - // Arrange — the same id written twice: the old timestamp must not survive the second write, - // or an actively resumed conversation would still age out from under it. + // Arrange — proves the window is a real filter, not "sweep everything": the live probe report + // (Q3) had only ever exercised a single row. PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("c1", Token); - await store.SaveContinuationAsync("c1", "c1", Envelope(), Token); - await ExecuteAsync("UPDATE agentcore.response_continuation SET updated_at = now() - interval '90 days'"); - await store.SaveContinuationAsync("c1", "c1", Envelope(), Token); + await store.SaveContinuationAsync("resp_old", "c1", Token); + await store.SaveContinuationAsync("resp_new", "c1", Token); + await ExecuteAsync( + "UPDATE agentcore.response_continuation SET created_at = now() - interval '90 days' WHERE store_id = 'resp_old'"); // Act int swept = await store.SweepAsync(Window, cancellationToken: Token); // Assert - Assert.Equal(0, swept); - Assert.NotNull(await store.GetContinuationAsync("c1", Token)); + Assert.Equal(1, swept); + Assert.Null(await store.FindContinuationAsync("resp_old", Token)); + Assert.Equal("c1", await store.FindContinuationAsync("resp_new", Token)); } [PostgresFact] - public async Task SweepAsync_MoreExpiredContinuationsThanOneBatch_LoopsUntilNoneAreLeft() + public async Task SweepAsync_MoreExpiredResponseIdsThanOneBatch_LoopsUntilNoneAreLeft() { // Arrange PostgresConversationStore store = new(DataSource); @@ -118,10 +113,10 @@ public async Task SweepAsync_MoreExpiredContinuationsThanOneBatch_LoopsUntilNone for (int i = 0; i < 5; i++) { - await store.SaveContinuationAsync($"resp_{i}", "c1", Envelope(), Token); + await store.SaveContinuationAsync($"resp_{i}", "c1", Token); } - await ExecuteAsync("UPDATE agentcore.response_continuation SET updated_at = now() - interval '90 days'"); + await ExecuteAsync("UPDATE agentcore.response_continuation SET created_at = now() - interval '90 days'"); // Act int swept = await store.SweepAsync(Window, batchSize: 2, cancellationToken: Token); diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs index 2fe40cf5..bee6f8be 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTests.cs @@ -199,21 +199,19 @@ public async Task DeleteAsync_AConversation_LeavesNoRowAndNoClaim() } [PostgresFact] - public async Task DeleteAsync_AConversation_TakesItsSameIdContinuationWithIt() + public async Task DeleteAsync_AConversation_TakesItsResponseContinuationsWithIt() { - // Arrange — one id plays three roles now, so the row delete takes the key with it. Per-turn - // response ids are gone with their own rows only through DeleteContinuationAsync; nothing - // here enumerates them. + // Arrange — a response id's row is FK'd to the conversation it continues (ON DELETE CASCADE), + // so deleting the conversation must take it too, or a reused id could resolve a dead conversation. PostgresConversationStore store = new(DataSource); _ = await store.CreateAsync("c1", Token); - using JsonDocument document = JsonDocument.Parse("""{ "conversationId": "c1" }"""); - await store.SaveContinuationAsync("c1", "c1", document.RootElement, Token); + await store.SaveContinuationAsync("resp_1", "c1", Token); // Act await store.DeleteAsync("c1", Token); // Assert - Assert.Null(await store.GetContinuationAsync("c1", Token)); + Assert.Null(await store.FindContinuationAsync("resp_1", Token)); } [PostgresFact] diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs new file mode 100644 index 00000000..e09db414 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreTurnConflictTests.cs @@ -0,0 +1,142 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Transcript; +using AgentCore.Infrastructure.Conversation.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// The store refuses a turn the conversation already saved, in PostgreSQL (owner ruling 2026-09-24, "refuse at + /// save"): two requests, on one machine or two, that resumed the same conversation both run its next turn, and + /// exactly one commit lands. + /// +#pragma warning disable CA1859 // store is typed as IConversationStore throughout, as every caller holds it. + public sealed class PostgresConversationStoreTurnConflictTests : PostgresDatabaseTest + { + /// + protected override bool Migrated => true; + + [PostgresFact] + public async Task AppendAsync_ATurnAlreadySaved_IsRefused_AndWritesNeitherRowsNorState() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + _ = await store.CreateAsync("c1", Token); + _ = await CommitAsync(store, "c1", 0, "opener"); + _ = await CommitAsync(store, "c1", 1, "first"); + + // Act + Exception? refused = await Record.ExceptionAsync(() => CommitAsync(store, "c1", 1, "second")); + + // Assert + Assert.IsType(refused); + IReadOnlyList rows = await store.ReadAllAsync("c1", Token); + Assert.Equal(["opener-user", "opener-reply", "first-user", "first-reply"], rows.Select(row => row.MessageId)); + ConversationRecord? record = await store.GetAsync("c1", Token); + Assert.Equal(("first", 2, 4), (record?.State?.Stage, record?.State?.NextTurnIndex, record?.NextOrdinal)); + } + + [PostgresFact] + public async Task AppendAsync_TheNextTurnAfterARefusal_AndAnAppendThatNamesNoTurn_BothLand() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + _ = await store.CreateAsync("c1", Token); + _ = await CommitAsync(store, "c1", 0, "opener"); + _ = await Record.ExceptionAsync(() => CommitAsync(store, "c1", 0, "late")); + + // Act + ConversationMessage outside = await store.AppendMessageAsync("c1", new ChatMessage(ChatRole.User, "outside"), Token); + IReadOnlyList next = await CommitAsync(store, "c1", 1, "next"); + + // Assert + Assert.Equal((2, 1), (outside.Ordinal, outside.TurnIndex)); + Assert.Equal([(3, 1), (4, 1)], next.Select(row => (row.Ordinal, row.TurnIndex))); + } + + [PostgresFact] + public async Task AppendAsync_ManyCommitsOfOneTurnAtOnce_KeepsExactlyOne() + { + // Every writer runs on its own pooled connection, so each is its own transaction on the server: the + // losers block on the conversation row until the winner commits, then re-check the turn against it. + IConversationStore store = new PostgresConversationStore(DataSource); + + for (int iteration = 0; iteration < 50; iteration++) + { + // Arrange + string conversationId = "race-" + iteration; + int parties = 2 + (iteration % 7); + _ = await store.CreateAsync(conversationId, Token); + _ = await CommitAsync(store, conversationId, 0, "opener"); + TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); + + // Act + Task[] commits = [.. Enumerable.Range(0, parties).Select(async party => + { + await start.Task; + try + { + _ = await CommitAsync(store, conversationId, 1, "writer" + party); + return true; + } + catch (ConversationTurnConflictException) + { + return false; + } + })]; + start.SetResult(); + bool[] kept = await Task.WhenAll(commits); + + // Assert + int winner = Assert.Single(Enumerable.Range(0, parties), party => kept[party]); + IReadOnlyList rows = await store.ReadAllAsync(conversationId, Token); + Assert.Equal( + ["opener-user", "opener-reply", $"writer{winner}-user", $"writer{winner}-reply"], + rows.Select(row => row.MessageId)); + ConversationRecord? record = await store.GetAsync(conversationId, Token); + Assert.Equal(("writer" + winner, 2), (record?.State?.Stage, record?.State?.NextTurnIndex)); + } + } + + [PostgresFact] + public async Task AppendAsync_AStateOlderThanTheStoredOne_WritesTheWordsButNotTheState() + { + // Arrange + IConversationStore store = new PostgresConversationStore(DataSource); + _ = await store.CreateAsync("c1", Token); + _ = await CommitAsync(store, "c1", 0, "opener"); + _ = await CommitAsync(store, "c1", 1, "first"); + + // Act + IReadOnlyList written = await store.AppendAsync( + "c1", + [new ConversationMessageDraft(TurnIndex: null, new ChatMessage(ChatRole.User, "outside"), "outside")], + new ConversationSessionState { NextTurnIndex = 1, Stage = "stale" }, + Token); + + // Assert + Assert.Equal("outside", Assert.Single(written).MessageId); + ConversationRecord? record = await store.GetAsync("c1", Token); + Assert.Equal(("first", 2), (record?.State?.Stage, record?.State?.NextTurnIndex)); + } + + /// Commits one turn as a session does: the user's words and the reply, with the state after them. + private static async Task> CommitAsync( + IConversationStore store, string conversationId, int turnIndex, string label) + { + return await store.AppendAsync( + conversationId, + [ + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.User, label), label + "-user"), + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Assistant, "re " + label), label + "-reply"), + ], + new ConversationSessionState { NextTurnIndex = turnIndex + 1, Stage = label }, + Token); + } + } +#pragma warning restore CA1859 +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWordsTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWordsTests.cs index 95b0479c..77580fb8 100644 --- a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWordsTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationStoreWordsTests.cs @@ -1,9 +1,6 @@ using System.Text.Json.Nodes; using AgentCore.Application.Conversation; using AgentCore.Application.Transcript; -using AgentCore.Domain.Audit; -using AgentCore.Domain.Sources; -using AgentCore.Infrastructure.Audit.Postgres; using AgentCore.Infrastructure.Conversation.Postgres; using AgentCore.Infrastructure.Tests.Database.Postgres; using AgentCore.TestSupport; @@ -299,92 +296,6 @@ public async Task EraseAsync_OneConversation_DeletesItsRowsAndLeavesTheOthers() - // --------------------------------------------------------------------------------------------- - // Store 1 against store 3. - // --------------------------------------------------------------------------------------------- - [PostgresFact] - public async Task ReadSpokenTurnsAsync_AToolCallingTurn_ReturnsOneRowForThatTurn() - { - // Arrange — the DISTINCT ON guard. The turn writes an assistant message carrying the tool - // conversation and another carrying the reply; without it both come back, and the textless one - // reports a false tamper on every tool-calling turn. - PostgresConversationStore store = await OpenAsync(); - _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); - - // Act - IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); - - // Assert - TranscriptTurnDigest turn = Assert.Single(turns); - Assert.Equal("Order 41 ships Friday.", turn.Spoken); - } - - [PostgresFact] - public async Task ReadSpokenTurnsAsync_AToolCallingTurn_MatchesTheHashTheChainHolds() - { - // Arrange - PostgresConversationStore store = await OpenAsync(); - _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); - - // Act - IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); - - // Assert — what the nightly check does: hash the words store 1 holds, and compare. - Assert.Equal(AuditHash.OfText(turns[0].Spoken).Value, turns[0].ReplyTextSha256); - } - - [PostgresFact] - public async Task ReadSpokenTurnsAsync_AToolCallingTurnThatAlsoCited_MatchesTheHashTheChainHolds() - { - // Arrange — a SourceContent rides the tool-result message alongside its FunctionResultContent. - // The verify query never looks at that row's role, but jsonb round-tripping a second content - // type on it must not upset the DISTINCT ON guard or the hash comparison. - PostgresConversationStore store = await OpenAsync(); - _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday.", cited: true); - - // Act - IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); - - // Assert - TranscriptTurnDigest turn = Assert.Single(turns); - Assert.Equal(AuditHash.OfText(turn.Spoken).Value, turn.ReplyTextSha256); - } - - [PostgresFact] - public async Task ReadSpokenTurnsAsync_ATurnWhoseWordsWereErased_ReturnsNoRowForIt() - { - // Arrange — the erasure working, and not a tamper. The chain keeps its row and goes on - // proving what it proved. - PostgresConversationStore store = await OpenAsync(); - _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); - _ = await store.EraseAsync("C1", Token); - - // Act - IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); - - // Assert - Assert.Empty(turns); - Assert.Equal(1L, await ScalarAsync("SELECT count(*) FROM agentcore.audit_event")); - } - - [PostgresFact] - public async Task ReadSpokenTurnsAsync_ATurnAmendedInTheChain_ReturnsOneRowHoldingTheLatestHash() - { - // Arrange — a barge-in amends a turn, so the chain carries a second turn.completed for it. - // Without the guard on the chain side the join multiplies and one turn answers twice. - PostgresConversationStore store = await OpenAsync(); - Guid firstEventId = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); - await store.RewriteAsync("C1", "m3", new ChatMessage(ChatRole.Assistant, "Order 41 sh"), Token); - await AmendTurnAsync("C1", turnIndex: 0, amends: firstEventId, spoken: "Order 41 sh"); - - // Act - IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); - - // Assert - TranscriptTurnDigest turn = Assert.Single(turns); - Assert.Equal(AuditHash.OfText("Order 41 sh").Value, turn.ReplyTextSha256); - } - /// /// One ordinary turn: what the caller said, and what the caller heard. /// only keeps message ids unique across appends to the same conversation — the store assigns the @@ -397,79 +308,5 @@ private static ConversationMessageDraft[] Turn(int turnIndex, int idSeed) new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Assistant, "Order 41 ships Friday."), $"m{idSeed + 1}"), ]; } - - /// Writes a tool-calling turn to store 1 and its turn.completed row to store 3. - private async Task WriteToolCallingTurnAsync( - PostgresConversationStore store, string conversationId, int turnIndex, string spoken, bool cited = false) - { - List toolResultContents = [new FunctionResultContent("id1", "Friday")]; - if (cited) - { - toolResultContents.Add(new SourceContent - { - Source = new SourceReference - { - SourceId = "card-42", - Kind = SourceKind.Document, - Title = "Order lookup", - Origin = "knowledge", - }, - CallId = "id1", - }); - } - - _ = await store.AppendAsync( - conversationId, - [ - new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.User, "what about order 41"), "m0"), - new ConversationMessageDraft(turnIndex, new ChatMessage( - ChatRole.Assistant, [new FunctionCallContent("id1", "lookup", null)]), "m1"), - new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Tool, toolResultContents), "m2"), - new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Assistant, spoken), "m3"), - ], - cancellationToken: Token); - - // Not disposed: the sink would take the test's own pool with it. - Guid eventId = Guid.CreateVersion7(); - PostgresAuditSink chain = new(DataSource); - await chain.AppendAsync( - new AuditEvent - { - ConversationId = conversationId, - EventId = eventId, - Kind = AuditEventKind.TurnCompleted, - OccurredAt = new DateTimeOffset(2026, 8, 19, 9, 0, 1, TimeSpan.Zero), - TurnIndex = turnIndex, - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(spoken).Value, - }, - }, - Token); - - return eventId; - } - - /// Writes a second turn.completed that corrects the first, as a barge-in does. - private async Task AmendTurnAsync(string conversationId, int turnIndex, Guid amends, string spoken) - { - // Not disposed: the sink would take the test's own pool with it. - PostgresAuditSink chain = new(DataSource); - await chain.AppendAsync( - new AuditEvent - { - ConversationId = conversationId, - EventId = Guid.CreateVersion7(), - Kind = AuditEventKind.TurnCompleted, - OccurredAt = new DateTimeOffset(2026, 8, 19, 9, 0, 2, TimeSpan.Zero), - TurnIndex = turnIndex, - AmendsEventId = amends, - Payload = new Dictionary(StringComparer.Ordinal) - { - [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(spoken).Value, - }, - }, - Token); - } } } diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs new file mode 100644 index 00000000..37454b44 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresConversationVerificationTests.cs @@ -0,0 +1,253 @@ +using AgentCore.Application.Conversation; +using AgentCore.Application.Transcript; +using AgentCore.Domain.Audit; +using AgentCore.Domain.Sources; +using AgentCore.Infrastructure.Audit.Postgres; +using AgentCore.Infrastructure.Conversation.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// Store 1 against store 3: what + /// rebuilds from the words, beside the hash the audit chain holds for them. + /// + public sealed class PostgresConversationVerificationTests : PostgresDatabaseTest + { + /// Opens the store with the conversation rows these tests write words against already made. + private async Task OpenAsync() + { + PostgresConversationStore store = new(DataSource); + _ = await store.CreateAsync("C1", Token); + _ = await store.CreateAsync("C2", Token); + return store; + } + + /// + protected override bool Migrated => true; + + [PostgresFact] + public async Task ReadSpokenTurnsAsync_AToolCallingTurn_ReturnsOneRowForThatTurn() + { + // Arrange — the DISTINCT ON guard. The turn writes an assistant message carrying the tool + // conversation and another carrying the reply; without it both come back, and the textless one + // reports a false tamper on every tool-calling turn. + PostgresConversationStore store = await OpenAsync(); + _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); + + // Act + IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); + + // Assert + TranscriptTurnDigest turn = Assert.Single(turns); + Assert.Equal("Order 41 ships Friday.", turn.Spoken); + } + + [PostgresFact] + public async Task ReadSpokenTurnsAsync_AToolCallingTurn_MatchesTheHashTheChainHolds() + { + // Arrange + PostgresConversationStore store = await OpenAsync(); + _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); + + // Act + IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); + + // Assert — what the nightly check does: hash the words store 1 holds, and compare. + Assert.Equal(AuditHash.OfText(turns[0].Spoken).Value, turns[0].ReplyTextSha256); + } + + [PostgresFact] + public async Task ReadSpokenTurnsAsync_AToolCallingTurnThatAlsoCited_MatchesTheHashTheChainHolds() + { + // Arrange — a SourceContent rides the tool-result message alongside its FunctionResultContent. + // The verify query never looks at that row's role, but jsonb round-tripping a second content + // type on it must not upset the DISTINCT ON guard or the hash comparison. + PostgresConversationStore store = await OpenAsync(); + _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday.", cited: true); + + // Act + IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); + + // Assert + TranscriptTurnDigest turn = Assert.Single(turns); + Assert.Equal(AuditHash.OfText(turn.Spoken).Value, turn.ReplyTextSha256); + } + + [PostgresFact] + public async Task ReadSpokenTurnsAsync_ATurnWhoseWordsWereErased_ReturnsNoRowForIt() + { + // Arrange — the erasure working, and not a tamper. The chain keeps its row and goes on + // proving what it proved. + PostgresConversationStore store = await OpenAsync(); + _ = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); + _ = await store.EraseAsync("C1", Token); + + // Act + IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); + + // Assert + Assert.Empty(turns); + Assert.Equal(1L, await ScalarAsync("SELECT count(*) FROM agentcore.audit_event")); + } + + [PostgresFact] + public async Task ReadSpokenTurnsAsync_ATurnAmendedInTheChain_ReturnsOneRowHoldingTheLatestHash() + { + // Arrange — a barge-in amends a turn, so the chain carries a second turn.completed for it. + // Without the guard on the chain side the join multiplies and one turn answers twice. + PostgresConversationStore store = await OpenAsync(); + Guid firstEventId = await WriteToolCallingTurnAsync(store, "C1", turnIndex: 0, spoken: "Order 41 ships Friday."); + await store.RewriteAsync("C1", "m3", new ChatMessage(ChatRole.Assistant, "Order 41 sh"), Token); + await AmendTurnAsync("C1", turnIndex: 0, amends: firstEventId, spoken: "Order 41 sh"); + + // Act + IReadOnlyList turns = await store.ReadSpokenTurnsAsync("C1", Token); + + // Assert + TranscriptTurnDigest turn = Assert.Single(turns); + Assert.Equal(AuditHash.OfText("Order 41 sh").Value, turn.ReplyTextSha256); + } + + [PostgresFact] + public async Task ReadSpokenTurnsAsync_AnEarlierStepTampered_NoLongerMatchesTheHash() + { + // Arrange — a multi-step turn: the model announces the lookup in prose on the same message as + // its call, then answers once the result is in. Both text rows belong to the reply, so the + // baseline must already match before the tamper proves anything. + PostgresConversationStore store = await OpenAsync(); + _ = await WriteMultiStepTurnAsync( + store, "C1", turnIndex: 0, announced: "Let me check that.", spoken: "Order 41 ships Friday."); + + TranscriptTurnDigest before = Assert.Single(await store.ReadSpokenTurnsAsync("C1", Token)); + Assert.Equal(AuditHash.OfText(before.Spoken).Value, before.ReplyTextSha256); + + // Act — tamper the EARLIER step's row (m1), not the last one. + await store.RewriteAsync( + "C1", + "m1", + new ChatMessage(ChatRole.Assistant, [new TextContent("Something else."), new FunctionCallContent("id1", "lookup", null)]), + Token); + + // Assert — a chain that only ever proved the last row would miss this. + TranscriptTurnDigest after = Assert.Single(await store.ReadSpokenTurnsAsync("C1", Token)); + Assert.NotEqual(AuditHash.OfText(after.Spoken).Value, after.ReplyTextSha256); + } + + /// Writes a tool-calling turn to store 1 and its turn.completed row to store 3. + private async Task WriteToolCallingTurnAsync( + PostgresConversationStore store, string conversationId, int turnIndex, string spoken, bool cited = false) + { + List toolResultContents = [new FunctionResultContent("id1", "Friday")]; + if (cited) + { + toolResultContents.Add(new SourceContent + { + Source = new SourceReference + { + SourceId = "card-42", + Kind = SourceKind.Document, + Title = "Order lookup", + Origin = "knowledge", + }, + CallId = "id1", + }); + } + + _ = await store.AppendAsync( + conversationId, + [ + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.User, "what about order 41"), "m0"), + new ConversationMessageDraft(turnIndex, new ChatMessage( + ChatRole.Assistant, [new FunctionCallContent("id1", "lookup", null)]), "m1"), + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Tool, toolResultContents), "m2"), + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Assistant, spoken), "m3"), + ], + cancellationToken: Token); + + // Not disposed: the sink would take the test's own pool with it. + Guid eventId = Guid.CreateVersion7(); + PostgresAuditSink chain = new(DataSource); + await chain.AppendAsync( + new AuditEvent + { + ConversationId = conversationId, + EventId = eventId, + Kind = AuditEventKind.TurnCompleted, + OccurredAt = new DateTimeOffset(2026, 8, 19, 9, 0, 1, TimeSpan.Zero), + TurnIndex = turnIndex, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(spoken).Value, + }, + }, + Token); + + return eventId; + } + + /// + /// Writes a two-step turn to store 1 — the tool announced on the same message as its call, then the + /// reply once the result is in — and its turn.completed row to store 3, hashed over both steps. + /// + private async Task WriteMultiStepTurnAsync( + PostgresConversationStore store, string conversationId, int turnIndex, string announced, string spoken) + { + _ = await store.AppendAsync( + conversationId, + [ + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.User, "what about order 41"), "m0"), + new ConversationMessageDraft(turnIndex, new ChatMessage( + ChatRole.Assistant, [new TextContent(announced), new FunctionCallContent("id1", "lookup", null)]), "m1"), + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Tool, [new FunctionResultContent("id1", "Friday")]), "m2"), + new ConversationMessageDraft(turnIndex, new ChatMessage(ChatRole.Assistant, spoken), "m3"), + ], + cancellationToken: Token); + + // Not disposed: the sink would take the test's own pool with it. + Guid eventId = Guid.CreateVersion7(); + PostgresAuditSink chain = new(DataSource); + await chain.AppendAsync( + new AuditEvent + { + ConversationId = conversationId, + EventId = eventId, + Kind = AuditEventKind.TurnCompleted, + OccurredAt = new DateTimeOffset(2026, 8, 19, 9, 0, 1, TimeSpan.Zero), + TurnIndex = turnIndex, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(announced + spoken).Value, + }, + }, + Token); + + return eventId; + } + + /// Writes a second turn.completed that corrects the first, as a barge-in does. + private async Task AmendTurnAsync(string conversationId, int turnIndex, Guid amends, string spoken) + { + // Not disposed: the sink would take the test's own pool with it. + PostgresAuditSink chain = new(DataSource); + await chain.AppendAsync( + new AuditEvent + { + ConversationId = conversationId, + EventId = Guid.CreateVersion7(), + Kind = AuditEventKind.TurnCompleted, + OccurredAt = new DateTimeOffset(2026, 8, 19, 9, 0, 2, TimeSpan.Zero), + TurnIndex = turnIndex, + AmendsEventId = amends, + Payload = new Dictionary(StringComparer.Ordinal) + { + [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(spoken).Value, + }, + }, + Token); + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs new file mode 100644 index 00000000..976fac80 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresLostAppendTests.cs @@ -0,0 +1,115 @@ +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Ports; +using AgentCore.Application.Runtime; +using AgentCore.Application.Transcript; +using AgentCore.Domain; +using AgentCore.Infrastructure.Conversation.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using AgentCore.TestSupport; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// A turn's append that PostgreSQL committed, whose answer never reached the session: the session counts it lost, + /// and must still tell it from another host's write when it next reads the store. + /// + public sealed class PostgresLostAppendTests : PostgresDatabaseTest + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + agents: + items: + - { id: parent, instructions: "delegate work", model: { ref: parent }, background: [blocker] } + - { id: blocker, instructions: "work forever", model: { ref: blocker } } + entries: + main: + agent: parent + """; + + /// + protected override bool Migrated => true; + + [PostgresFact] + public async Task AnAppendTheStoreCommittedButReportedFailed_OnOneHost_LeavesTheBackgroundTaskRunning() + { + // Arrange + SavesThenFailsStore store = new(new PostgresConversationStore(DataSource)); + BackgroundStatusChatClient parent = new(); + using HangingChildChatClient child = new(); + await using ConversationSession session = Create(store, parent, child); + + _ = await session.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + store.SavesThenFails = true; + _ = await session.RunTurnAsync("again", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + + // Act + TurnResult next = await session.RunTurnAsync("status?", Token); + + // Assert + Assert.Equal(2, next.TurnIndex); + string status = Assert.Single(parent.Statuses); + Assert.Contains("Task 1 [Running]", status, StringComparison.Ordinal); + } + + [PostgresFact] + public async Task AnAppendTheStoreCommittedButReportedFailed_ThenALateCutThatRemovesItsReply_OnOneHost_LeavesTheBackgroundTaskRunning() + { + // Arrange + SavesThenFailsStore store = new(new PostgresConversationStore(DataSource)); + BackgroundStatusChatClient parent = new(); + using HangingChildChatClient child = new(); + await using ConversationSession session = Create(store, parent, child); + + _ = await session.RunTurnAsync("go", Token); + await child.Started.WaitAsync(TimeSpan.FromSeconds(10), Token); + store.SavesThenFails = true; + _ = await session.RunTurnAsync("again", Token); + await session.FlushTranscriptAsync(); + store.SavesThenFails = false; + + // A barge-in before any word of turn 1's reply played leaves its reply row empty, so the session deletes it. + Assert.True(session.Cut(1, new TurnCut(string.Empty, null))); + await session.FlushTranscriptAsync(); + + // Act + TurnResult next = await session.RunTurnAsync("status?", Token); + + // Assert + Assert.Equal(2, next.TurnIndex); + string status = Assert.Single(parent.Statuses); + Assert.Contains("Task 1 [Running]", status, StringComparison.Ordinal); + } + + private static ConversationSession Create(IConversationStore store, BackgroundStatusChatClient parent, HangingChildChatClient child) + { + RoutingChatClientFactory chatClients = new(parent); + _ = chatClients.Route("parent", parent); + _ = chatClients.Route("blocker", child); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + ConfigurationLoader.LoadYaml(Yaml), new AgentCompilationContext(chatClients) { ConversationStore = store })["main"]; + + return new ConversationSessionFactory(compiled, new GuardEvaluator(compiled.Configuration.Guards)).Create(); + } + + /// A store whose appends, while is set, commit and then fail. + private sealed class SavesThenFailsStore(IConversationStore inner) : DelegatingConversationStore(inner) + { + public bool SavesThenFails { get; set; } + + public override async ValueTask> AppendAsync( + string conversationId, IReadOnlyList messages, ConversationSessionState? state = null, CancellationToken cancellationToken = default) + { + IReadOnlyList rows = await base.AppendAsync(conversationId, messages, state, cancellationToken); + return SavesThenFails ? throw new TimeoutException("the answer to a committed append was lost.") : rows; + } + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs new file mode 100644 index 00000000..8498fc3c --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Conversation/Postgres/PostgresTurnVerificationTests.cs @@ -0,0 +1,120 @@ +using AgentCore.Application.Audit.Memory; +using AgentCore.Application.Configuration.Compilation; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Configuration.Validation; +using AgentCore.Application.Conversation; +using AgentCore.Application.Runtime; +using AgentCore.Domain.Audit; +using AgentCore.Infrastructure.Audit.Postgres; +using AgentCore.Infrastructure.Conversation.Postgres; +using AgentCore.Infrastructure.Tests.Database.Postgres; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Conversation.Postgres +{ + /// + /// A real turn's words in store 1 and its events in store 3, checked the way the store's verify query checks + /// them: the words the rows say must hash to what the chain proves, whatever way the turn ended. + /// + public sealed class PostgresTurnVerificationTests : PostgresDatabaseTest + { + private const string Yaml = + """ + apiVersion: agentcore/v1 + tools: + - { id: price_lookup, kind: builtin, uses: orders.read, description: "Look up the price of an item." } + agents: + items: + - { id: only, instructions: "quote the price", tools: [ price_lookup ] } + entries: + main: + agent: only + """; + + /// + protected override bool Migrated => true; + + [PostgresFact] + public async Task AToolThatSpendsItsBudgetAfterTheModelSpoke_VerifiesAgainstTheChain() + { + // Arrange + PostgresConversationStore store = new(DataSource); + InMemoryAuditSink events = new(); + ConversationSession session = Create( + store, events, new AnnouncingToolChatClient(answer: null), () => throw new TimeoutException("the tool is down.")); + + // Act + _ = await session.RunTurnAsync("what does it cost", Token); + TranscriptTurnDigest turn = await VerifyAsync(store, events, session); + + // Assert + Assert.Equal( + "Checking 1. Checking 2. Checking 3. Checking 4.I am sorry. I could not finish that. Please say it again.", + turn.Spoken); + Assert.Equal("40e79eac3b0c25fe5d27e0fd6e0ee02aecc48ff93b81af341e0ec3cdb875812d", turn.ReplyTextSha256); + } + + [PostgresFact] + public async Task AReaderThatLeavesDuringTheSecondStep_VerifiesAgainstTheChain() + { + // Arrange + PostgresConversationStore store = new(DataSource); + InMemoryAuditSink events = new(); + ConversationSession session = Create( + store, events, new AnnouncingToolChatClient("the price is fifty"), () => "{ \"price\": 50 }"); + + // Act + await foreach (ChatResponseUpdate update in session.RunTurnStreamingAsync("what does it cost", Token)) + { + if (update.Text == "the") + { + break; + } + } + + TranscriptTurnDigest turn = await VerifyAsync(store, events, session); + + // Assert + Assert.Equal("Checking 1. the", turn.Spoken); + Assert.Equal("3ee72d35ecf4692c68e8a81bf13607d2096fb1e58b4eb133c45f40ae583ced68", turn.ReplyTextSha256); + } + + private static ConversationSession Create( + PostgresConversationStore store, InMemoryAuditSink events, IChatClient model, Func tool) + { + AgentCoreConfiguration document = ConfigurationLoader.LoadYaml(Yaml); + CompiledAgent compiled = ConfigurationCompiler.CompileAll( + document, + new AgentCompilationContext(new RoutingChatClientFactory(model)) + { + ConversationStore = store, + Tools = TestToolRegistry.From( + document, declared => AIFunctionFactory.Create(tool, declared.Id, declared.Description ?? declared.Id), Token), + })["main"]; + + return new ConversationSessionFactory( + compiled, + new GuardEvaluator(compiled.Configuration.Guards), + observers: ConversationObservers.Standard(events, logger: null)).Create(); + } + + /// Files the session's events in store 3 and runs the store's verify query over turn 0. + private async Task VerifyAsync( + PostgresConversationStore store, InMemoryAuditSink events, ConversationSession session) + { + await session.FlushTranscriptAsync(); + + // Not disposed: the sink would take the test's own pool with it. + PostgresAuditSink chain = new(DataSource); + foreach (AuditEvent raised in events.EventsOf(session.ConversationId)) + { + await chain.AppendAsync(raised, Token); + } + + return Assert.Single(await store.ReadSpokenTurnsAsync(session.ConversationId, Token)); + } + } +} diff --git a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresDatabaseTest.cs b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresDatabaseTest.cs index 143bf0a9..09ddc5d0 100644 --- a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresDatabaseTest.cs +++ b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresDatabaseTest.cs @@ -58,6 +58,22 @@ protected async Task ExecuteAsync(string sql) _ = await command.ExecuteNonQueryAsync(Token); } + /// Opens a pool that logs in as an ordinary member of agentcore_writer. + protected async Task OpenAsWriterAsync() + { + string login = "agentcore_member_" + Guid.NewGuid().ToString("n"); + + await ExecuteAsync($"CREATE ROLE \"{login}\" LOGIN PASSWORD 'member' NOSUPERUSER NOCREATEDB NOCREATEROLE"); + await ExecuteAsync($"GRANT agentcore_writer TO \"{login}\""); + + return NpgsqlDataSource.Create( + new NpgsqlConnectionStringBuilder(Database.ConnectionString) + { + Username = login, + Password = "member", + }); + } + /// Runs a statement and reads its first value. protected async Task ScalarAsync(string sql) { diff --git a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs index 73b962b4..6b1cbcf2 100644 --- a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaTests.cs @@ -1,3 +1,4 @@ +using AgentCore.Infrastructure.Conversation.Postgres; using AgentCore.Infrastructure.Database.Postgres; using Npgsql; using Xunit; @@ -18,6 +19,7 @@ public sealed class PostgresSchemaTests : PostgresDatabaseTest [InlineData("agentcore.conversation_message")] [InlineData("agentcore.audit_event")] [InlineData("agentcore.schema_migration")] + [InlineData("agentcore.conversation_busy")] public async Task ApplyAsync_FreshDatabase_CreatesTheTable(string table) { // Arrange @@ -218,9 +220,41 @@ await ExecuteAsync( } [PostgresFact] - public async Task Versions_AreTheOneMigrationThisAssemblyCarries() + public async Task Versions_AreTheMigrationsThisAssemblyCarries_InOrder() { - Assert.Equal(["001_agentcore"], PostgresSchema.Versions); + Assert.Equal(["001_agentcore", "002_conversation_busy", "003_response_continuation"], PostgresSchema.Versions); + } + + [PostgresFact] + public async Task ConversationBusy_TheWriterRole_MarksRenewsAndClears() + { + // Arrange + _ = await PostgresSchema.ApplyAsync(DataSource, Token); + await using NpgsqlDataSource asWriter = await OpenAsWriterAsync(); + PostgresConversationStore store = new(asWriter); + + // Act + bool marked = await store.TryMarkBusyAsync("c1", "host-a", TimeSpan.FromMinutes(1), Token); + bool renewed = await store.TryMarkBusyAsync("c1", "host-a", TimeSpan.FromMinutes(1), Token); + await store.ClearBusyAsync("c1", "host-a", Token); + + // Assert + Assert.Equal((true, true), (marked, renewed)); + Assert.Equal(0L, await ScalarAsync("SELECT count(*) FROM agentcore.conversation_busy")); + } + + [PostgresFact] + public async Task ConversationBusy_IsUnlogged_SoAMarkNeverWaitsForAWalFlush() + { + // Arrange + _ = await PostgresSchema.ApplyAsync(DataSource, Token); + + // Act + string persistence = await ScalarAsync( + "SELECT relpersistence::text FROM pg_class WHERE oid = 'agentcore.conversation_busy'::regclass"); + + // Assert + Assert.Equal("u", persistence); } [PostgresFact] @@ -272,22 +306,5 @@ INSERT INTO agentcore.audit_event (conversation_id, event_id, sequence, kind, oc VALUES ('C1', gen_random_uuid(), 1, 'conversation.started', now()) """); } - - /// Opens a pool that logs in as an ordinary member of agentcore_writer. - private async Task OpenAsWriterAsync() - { - string login = "agentcore_member_" + Guid.NewGuid().ToString("n"); - - await ExecuteAsync($"CREATE ROLE \"{login}\" LOGIN PASSWORD 'member' NOSUPERUSER NOCREATEDB NOCREATEROLE"); - await ExecuteAsync($"GRANT agentcore_writer TO \"{login}\""); - - return NpgsqlDataSource.Create( - new NpgsqlConnectionStringBuilder(Database.ConnectionString) - { - Username = login, - Password = "member", - }); - } - } } diff --git a/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaUpgradeTests.cs b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaUpgradeTests.cs new file mode 100644 index 00000000..5fc06235 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Database/Postgres/PostgresSchemaUpgradeTests.cs @@ -0,0 +1,152 @@ +using AgentCore.Infrastructure.Database.Postgres; +using Npgsql; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Database.Postgres +{ + /// + /// A database an earlier release migrated, met by a host of this one. The host applies what is pending as it + /// starts, on its own connection; a host that connects as a member of agentcore_writer may create nothing. + /// + public sealed class PostgresSchemaUpgradeTests : PostgresDatabaseTest + { + /// + protected override bool Migrated => false; + + [PostgresFact] + public async Task ApplyAsync_ADatabaseMigratedTo001_AppliesOnly002() + { + // Arrange: the state a database left by the 001-only release is in. + _ = await PostgresSchema.ApplyAsync(DataSource, Token); + await ExecuteAsync("DROP TABLE agentcore.conversation_busy"); + await ExecuteAsync("DELETE FROM agentcore.schema_migration WHERE version = '002_conversation_busy'"); + await ExecuteAsync("INSERT INTO agentcore.conversation (conversation_id) VALUES ('kept')"); + + // Act + IReadOnlyList applied = await PostgresSchema.ApplyAsync(DataSource, Token); + + // Assert + Assert.Equal(["002_conversation_busy"], applied); + Assert.True(await ScalarAsync("SELECT to_regclass('agentcore.conversation_busy') IS NOT NULL")); + Assert.Equal(1L, await ScalarAsync("SELECT count(*) FROM agentcore.conversation")); + } + + [PostgresFact] + public async Task ApplyAsync_AsTheWriterRoleOnADatabaseMigratedTo001_RefusesAndNamesTheMigrationAndWhoAppliesIt() + { + // Arrange + await MigrateTo001Async(); + await using NpgsqlDataSource asWriter = await OpenAsWriterAsync(); + + // Act + Exception? refusal = await Record.ExceptionAsync(() => PostgresSchema.ApplyAsync(asWriter, Token)); + + // Assert + InvalidOperationException failure = Assert.IsType(refusal); + Assert.Contains("002_conversation_busy", failure.Message, StringComparison.Ordinal); + Assert.Contains("the role that applied 001_agentcore", failure.Message, StringComparison.Ordinal); + Assert.Contains("PostgresSchema.ApplyAsync", failure.Message, StringComparison.Ordinal); + Assert.Equal("42501", Assert.IsType(failure.InnerException).SqlState); + Assert.False(await ScalarAsync("SELECT to_regclass('agentcore.conversation_busy') IS NOT NULL")); + } + + [PostgresFact] + public async Task ApplyAsync_AsTheWriterRoleOnAnEmptyDatabase_AsksForARoleThatMayCreateTheSchema() + { + // Arrange + await ExecuteAsync( + "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'agentcore_writer') " + + "THEN CREATE ROLE agentcore_writer NOLOGIN; END IF; END $$"); + await using NpgsqlDataSource asWriter = await OpenAsWriterAsync(); + + // Act + Exception? refusal = await Record.ExceptionAsync(() => PostgresSchema.ApplyAsync(asWriter, Token)); + + // Assert + InvalidOperationException failure = Assert.IsType(refusal); + Assert.Contains("001_agentcore, 002_conversation_busy", failure.Message, StringComparison.Ordinal); + Assert.Contains("may create the 'agentcore' schema", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("the role that applied", failure.Message, StringComparison.Ordinal); + Assert.Equal("42501", Assert.IsType(failure.InnerException).SqlState); + Assert.False(await ScalarAsync("SELECT to_regnamespace('agentcore') IS NOT NULL")); + } + + [PostgresFact] + public async Task ApplyAsync_AsTheWriterRoleAfterTheOwnerApplied002_Starts() + { + // Arrange + await MigrateTo001Async(); + await using NpgsqlDataSource asWriter = await OpenAsWriterAsync(); + _ = await Record.ExceptionAsync(() => PostgresSchema.ApplyAsync(asWriter, Token)); + _ = await PostgresSchema.ApplyAsync(DataSource, Token); + + // Act + IReadOnlyList applied = await PostgresSchema.ApplyAsync(asWriter, Token); + + // Assert + Assert.Empty(applied); + Assert.True(await ScalarAsync("SELECT to_regclass('agentcore.conversation_busy') IS NOT NULL")); + } + + [PostgresFact] + public async Task ApplyAsync_003_DeletesThePointerRow_AndLeavesTheEnvelopeRowAsAPlainRow() + { + // Arrange: the response_continuation shape a 002-only release left, with one pointer row + // (store_id names its own conversation, the old same-id resume path) and one real per-turn + // envelope row. + await MigrateTo002Async(); + await ExecuteAsync("INSERT INTO agentcore.conversation (conversation_id) VALUES ('conv_1')"); + await ExecuteAsync( + """ + INSERT INTO agentcore.response_continuation (store_id, conversation_id, envelope, created_at, updated_at) + VALUES ('conv_1', 'conv_1', '{"$continuationPointer":"resp_1"}'::jsonb, '2020-01-01T00:00:00+00', now()) + """); + await ExecuteAsync( + """ + INSERT INTO agentcore.response_continuation (store_id, conversation_id, envelope, created_at, updated_at) + VALUES ('resp_1', 'conv_1', '{"conversationId":"conv_1","state":{}}'::jsonb, '2020-01-01T00:00:00+00', now()) + """); + + // Act + IReadOnlyList applied = await PostgresSchema.ApplyAsync(DataSource, Token); + + // Assert + Assert.Equal(["003_response_continuation"], applied); + Assert.Equal(0L, await ScalarAsync("SELECT count(*) FROM agentcore.response_continuation WHERE store_id = 'conv_1'")); + Assert.Equal(1L, await ScalarAsync("SELECT count(*) FROM agentcore.response_continuation WHERE store_id = 'resp_1'")); + Assert.Equal( + new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), + await ScalarAsync("SELECT created_at FROM agentcore.response_continuation WHERE store_id = 'resp_1'")); + Assert.Equal( + 0L, + await ScalarAsync( + """ + SELECT count(*) FROM information_schema.columns + WHERE table_schema = 'agentcore' AND table_name = 'response_continuation' + AND column_name IN ('envelope', 'updated_at') + """)); + } + + /// Leaves the database as the 001-only release did. + private async Task MigrateTo001Async() + { + _ = await PostgresSchema.ApplyAsync(DataSource, Token); + await ExecuteAsync("DROP TABLE agentcore.conversation_busy"); + await ExecuteAsync("DELETE FROM agentcore.schema_migration WHERE version = '002_conversation_busy'"); + } + + /// Leaves the database as a 002-only release did: response_continuation still holds an envelope. + private async Task MigrateTo002Async() + { + _ = await PostgresSchema.ApplyAsync(DataSource, Token); + await ExecuteAsync("DELETE FROM agentcore.schema_migration WHERE version = '003_response_continuation'"); + await ExecuteAsync("DROP INDEX agentcore.response_continuation_retention_idx"); + await ExecuteAsync( + """ + ALTER TABLE agentcore.response_continuation + ADD COLUMN envelope jsonb NOT NULL, + ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now() + """); + } + } +} diff --git a/tests/AgentCore.TestSupport/DelegatingConversationStore.cs b/tests/AgentCore.TestSupport/DelegatingConversationStore.cs index 988a1ffa..d0dcbaad 100644 --- a/tests/AgentCore.TestSupport/DelegatingConversationStore.cs +++ b/tests/AgentCore.TestSupport/DelegatingConversationStore.cs @@ -91,6 +91,13 @@ public virtual ValueTask RewriteAsync( return Inner.RewriteAsync(conversationId, messageId, content, cancellationToken); } + /// + public virtual ValueTask DeleteMessageAsync( + string conversationId, string messageId, CancellationToken cancellationToken = default) + { + return Inner.DeleteMessageAsync(conversationId, messageId, cancellationToken); + } + /// public virtual ValueTask> ReadForSessionAsync( string conversationId, CancellationToken cancellationToken = default) @@ -133,6 +140,20 @@ public virtual ValueTask SweepAsync( return Inner.SweepAsync(retention, batchSize, cancellationToken); } + /// + public virtual ValueTask TryMarkBusyAsync( + string conversationId, string holder, TimeSpan lease, CancellationToken cancellationToken = default) + { + return Inner.TryMarkBusyAsync(conversationId, holder, lease, cancellationToken); + } + + /// + public virtual ValueTask ClearBusyAsync( + string conversationId, string holder, CancellationToken cancellationToken = default) + { + return Inner.ClearBusyAsync(conversationId, holder, cancellationToken); + } + /// public virtual ValueTask AttachPrincipalAsync( string conversationId, string principalKey, string role, CancellationToken cancellationToken = default) @@ -149,23 +170,23 @@ public virtual ValueTask DetachPrincipalAsync( /// public virtual ValueTask SaveContinuationAsync( - string continuationId, string conversationId, JsonElement envelope, CancellationToken cancellationToken = default) + string responseId, string conversationId, CancellationToken cancellationToken = default) { - return Inner.SaveContinuationAsync(continuationId, conversationId, envelope, cancellationToken); + return Inner.SaveContinuationAsync(responseId, conversationId, cancellationToken); } /// - public virtual ValueTask GetContinuationAsync( - string continuationId, CancellationToken cancellationToken = default) + public virtual ValueTask FindContinuationAsync( + string responseId, CancellationToken cancellationToken = default) { - return Inner.GetContinuationAsync(continuationId, cancellationToken); + return Inner.FindContinuationAsync(responseId, cancellationToken); } /// public virtual ValueTask DeleteContinuationAsync( - string continuationId, CancellationToken cancellationToken = default) + string responseId, CancellationToken cancellationToken = default) { - return Inner.DeleteContinuationAsync(continuationId, cancellationToken); + return Inner.DeleteContinuationAsync(responseId, cancellationToken); } } } diff --git a/tests/AgentCore.TestSupport/FakeTimeProvider.cs b/tests/AgentCore.TestSupport/FakeTimeProvider.cs index 5dc4b39a..01bc6895 100644 --- a/tests/AgentCore.TestSupport/FakeTimeProvider.cs +++ b/tests/AgentCore.TestSupport/FakeTimeProvider.cs @@ -14,16 +14,17 @@ namespace AgentCore.TestSupport /// due timer synchronously, with no wall-clock wait at all. /// /// - /// Every timer this class hands out is one-shot in practice — the idle deadline is the only - /// production caller today, and it always asks for as its - /// period — but still reschedules a periodic timer for its next due time, - /// so a future caller that does ask for a period is not silently starved. + /// A timer behaves as does. A one-shot timer that fired, or one + /// armed with , is not scheduled but not disposed either: + /// arms it again. reschedules a periodic timer for its + /// next due time. The timer counts only include scheduled timers. /// /// public sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider { private readonly Lock _gate = new(); private readonly List _timers = []; + private readonly List<(DateTimeOffset? DueAt, int Count, TaskCompletionSource Reached)> _waiters = []; private DateTimeOffset _now = start; public override DateTimeOffset GetUtcNow() @@ -34,19 +35,60 @@ public override DateTimeOffset GetUtcNow() } } + /// + /// Overridden with , because the base implementation reads + /// and would leave every elapsed-time measurement + /// taken against this fake running on the real clock underneath it. + /// + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() + { + lock (_gate) + { + return _now.UtcTicks; + } + } + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { ArgumentNullException.ThrowIfNull(callback); FakeTimer timer = new(this, callback, state); + Schedule(timer, dueTime, period); + return timer; + } + + /// Waits until exactly live timers are due at . + /// The instant the timers are due. + /// How many live timers to wait for. + /// A task that completes once the count matches. + public Task WaitForTimersAsync(DateTimeOffset dueAt, int count) + { + return WaitForCountAsync(dueAt, count); + } + + /// Waits until exactly live timers exist, whenever they are due. + /// How many live timers to wait for. + /// A task that completes once the count matches. + public Task WaitForTimersAsync(int count) + { + return WaitForCountAsync(null, count); + } + + private Task WaitForCountAsync(DateTimeOffset? dueAt, int count) + { lock (_gate) { - timer.DueAt = _now + dueTime; - timer.Period = period; - _timers.Add(timer); - } + if (CountLocked(dueAt) == count) + { + return Task.CompletedTask; + } - return timer; + TaskCompletionSource reached = new(TaskCreationOptions.RunContinuationsAsynchronously); + _waiters.Add((dueAt, count, reached)); + return reached.Task; + } } /// Moves the clock forward, and fires every timer whose due time this reaches or passes. @@ -72,14 +114,74 @@ public void Advance(TimeSpan delta) } } + private void Schedule(FakeTimer timer, TimeSpan dueTime, TimeSpan period) + { + lock (_gate) + { + timer.Period = period; + if (dueTime == Timeout.InfiniteTimeSpan) + { + _ = _timers.Remove(timer); + } + else + { + timer.DueAt = _now + dueTime; + if (!_timers.Contains(timer)) + { + _timers.Add(timer); + } + } + + ReleaseWaitersLocked(); + } + } + + /// + /// Claims one firing of a timer that is still scheduled and due. A timer changed or disposed after + /// picked it does not fire. + /// + private bool TryTakeDue(FakeTimer timer) + { + lock (_gate) + { + if (timer.Disposed || timer.DueAt > _now || !_timers.Contains(timer)) + { + return false; + } + + if (timer.Period == Timeout.InfiniteTimeSpan || timer.Period == TimeSpan.Zero) + { + _ = _timers.Remove(timer); + } + else + { + timer.DueAt = _now + timer.Period; + } + + ReleaseWaitersLocked(); + return true; + } + } + private void Remove(FakeTimer timer) { lock (_gate) { _ = _timers.Remove(timer); + ReleaseWaitersLocked(); } } + private int CountLocked(DateTimeOffset? dueAt) + { + return _timers.Count(timer => !timer.Disposed && (dueAt is null || timer.DueAt == dueAt)); + } + + private void ReleaseWaitersLocked() + { + _ = _waiters.RemoveAll(waiter => CountLocked(waiter.DueAt) == waiter.Count && waiter.Reached.TrySetResult()); + } + private sealed class FakeTimer(FakeTimeProvider owner, TimerCallback callback, object? state) : ITimer { public DateTimeOffset DueAt { get; set; } @@ -91,22 +193,10 @@ private sealed class FakeTimer(FakeTimeProvider owner, TimerCallback callback, o /// Runs the callback, and reschedules only when this timer asked for a period. public void Fire() { - if (Disposed) - { - return; - } - - if (Period == Timeout.InfiniteTimeSpan || Period == TimeSpan.Zero) + if (owner.TryTakeDue(this)) { - owner.Remove(this); - Disposed = true; + callback(state); } - else - { - DueAt = owner.GetUtcNow() + Period; - } - - callback(state); } public bool Change(TimeSpan dueTime, TimeSpan period) @@ -116,8 +206,7 @@ public bool Change(TimeSpan dueTime, TimeSpan period) return false; } - DueAt = owner.GetUtcNow() + dueTime; - Period = period; + owner.Schedule(this, dueTime, period); return true; } diff --git a/tests/AgentCore.TestSupport/SingleEntrySessionFactories.cs b/tests/AgentCore.TestSupport/SingleEntrySessionFactories.cs new file mode 100644 index 00000000..0ee73043 --- /dev/null +++ b/tests/AgentCore.TestSupport/SingleEntrySessionFactories.cs @@ -0,0 +1,17 @@ +using AgentCore.Application.Ports; + +namespace AgentCore.TestSupport +{ + /// The one entry name a single-factory test fixture opens under, and the map an owner takes for it. + public static class SingleEntrySessionFactories + { + /// The entry every single-factory test fixture opens its conversations under. + public const string MainEntry = "main"; + + /// Wraps one factory as the single-entry map owners take. + public static IReadOnlyDictionary Of(IConversationSessionFactory factory) + { + return new Dictionary(StringComparer.Ordinal) { [MainEntry] = factory }; + } + } +}