From 96322d1755b63a77afdcd37778640ec222832f79 Mon Sep 17 00:00:00 2001 From: DevMando Date: Thu, 10 Sep 2026 12:56:17 -0700 Subject: [PATCH] Let agents address each other by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing '@' now offers open agents ahead of project files, and an agent can check on another, read its conversation, ask it a real question, or hand it a whole job. Four tools, and only one of them wakes the other agent. Status, transcript reads and delegation progress are all assembled from state the host already keeps, so they cost nothing, cannot loop, and work while the target is mid-turn — which is exactly when 'have you finished X?' gets asked. An earlier design refused while busy; that would have failed the feature's most common question. The busy refusal now names the read tools instead, so busy degrades to reading rather than dead-ending. Delegation does not block. ask_agent held the asker's turn gate for the length of the job, so messages to it were dropped for minutes at a time; delegate_to_agent returns at once and reports back through an inbox. Progress is filed under ONE inbox id and overwritten, so a ten-minute job costs the same context as a ten-second one — an append-only feed would grow with the other agent's work and be paid for on every later turn. Completion appends a card to the delegating agent's transcript, which needs no model turn at all; the agent itself learns from its inbox next time it speaks. Identity is structural. The first version put attribution only in the host instruction, which the engine discards after the turn — so a relayed question stayed in history as an ordinary user turn, and an agent asked afterwards who it had been talking to could only report what the message 'claimed'. Every agent-to-agent message is now wrapped by the host, and Wrap strips any envelope already in the payload so 'only MandoCode adds this' is true rather than reassuring. The turn is also logged under its own role, so the distinction survives a restart — previously the question was not recorded at all, leaving a dangling answer in the re-brief and in read_agent_transcript. Turns are claimed, not checked. AnswerPeerAsync read the busy flag but never set it, so a peer question did not mark the agent busy: a user typing mid-answer, or a second agent asking at the same moment, got a concurrent turn on one chat history. One atomic gate now serves the user's turn, a peer's question and the setup wizard alike. --- CHANGELOG.md | 11 + docs/agent-mentions.md | 274 +++++++++++++++ src/MandoCode.Desktop.Tests/AgentAskTests.cs | 311 ++++++++++++++++++ .../AgentDirectoryTests.cs | 104 ++++++ .../AgentDirectoryToolsTests.cs | 98 ++++++ .../ConversationRoleTests.cs | 76 +++++ .../DelegationTests.cs | 253 ++++++++++++++ .../MandoCode.Desktop.Tests.csproj | 7 + .../PeerMessageEnvelopeTests.cs | 85 +++++ .../PeerTurnPolicyTests.cs | 80 +++++ src/MandoCode.Desktop/App.xaml.cs | 3 +- .../Assets/web/transcript/transcript.css | 13 +- .../Controls/ChatTabView.Input.cs | 33 +- .../Controls/ChatTabView.Transcript.cs | 4 +- .../Controls/ChatTabView.xaml.cs | 7 +- src/MandoCode.Desktop/MainWindow.History.cs | 7 +- src/MandoCode.Desktop/MainWindow.Split.cs | 6 +- src/MandoCode.Desktop/MainWindow.Tabs.cs | 15 + src/MandoCode.Desktop/MainWindow.Terminal.cs | 40 +++ src/MandoCode.Desktop/MainWindow.xaml.cs | 2 + .../Services/AgentCommandLog.cs | 21 ++ .../Services/AgentDirectory.cs | 155 +++++++++ .../Services/AgentDirectoryTools.cs | 194 +++++++++++ src/MandoCode.Desktop/Services/AgentInbox.cs | 93 ++++++ src/MandoCode.Desktop/Services/AgentPeer.cs | 106 ++++++ .../Services/AgentSession.cs | 65 +++- .../Services/ConversationLog.cs | 17 +- src/MandoCode.Desktop/Services/Delegations.cs | 187 +++++++++++ .../Services/PeerMessageEnvelope.cs | 44 +++ .../Services/PeerTurnPolicy.cs | 47 +++ .../Services/SessionAgentPeer.cs | 40 +++ .../ViewModels/ChatController.cs | 217 ++++++++++-- .../ViewModels/RequestPreambleComposer.cs | 11 +- 33 files changed, 2591 insertions(+), 35 deletions(-) create mode 100644 docs/agent-mentions.md create mode 100644 src/MandoCode.Desktop.Tests/AgentAskTests.cs create mode 100644 src/MandoCode.Desktop.Tests/AgentDirectoryTests.cs create mode 100644 src/MandoCode.Desktop.Tests/AgentDirectoryToolsTests.cs create mode 100644 src/MandoCode.Desktop.Tests/ConversationRoleTests.cs create mode 100644 src/MandoCode.Desktop.Tests/DelegationTests.cs create mode 100644 src/MandoCode.Desktop.Tests/PeerMessageEnvelopeTests.cs create mode 100644 src/MandoCode.Desktop.Tests/PeerTurnPolicyTests.cs create mode 100644 src/MandoCode.Desktop/Services/AgentDirectory.cs create mode 100644 src/MandoCode.Desktop/Services/AgentDirectoryTools.cs create mode 100644 src/MandoCode.Desktop/Services/AgentInbox.cs create mode 100644 src/MandoCode.Desktop/Services/AgentPeer.cs create mode 100644 src/MandoCode.Desktop/Services/Delegations.cs create mode 100644 src/MandoCode.Desktop/Services/PeerMessageEnvelope.cs create mode 100644 src/MandoCode.Desktop/Services/PeerTurnPolicy.cs create mode 100644 src/MandoCode.Desktop/Services/SessionAgentPeer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d84459..8124fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ for every approved plan. Desktop's version follows the engine generation, so it 0.15.0. ### Added +- **Agents can talk to each other.** Typing `@` now offers the other open agents before project + files, so you can address one by name from another's conversation. An agent can check what another + is doing, read its conversation, ask it a question and get a real answer back, or hand it a whole + job. Handing over a job does not block: your agent replies immediately and stays available while + the other works, tells you how far along it is whenever you ask, and announces the result in the + conversation when it finishes. Progress is summarised from what the other agent is actually doing + — the step it is on, the commands it has run — rather than by copying its conversation across, so + a job that runs for ten minutes costs no more to keep track of than one that runs for ten seconds. + Questions relayed between agents are labelled as such, so an agent always knows whether it is + talking to you or to another agent, and treats what another agent tells it as a claim rather than + a fact. - **Nine themes that imitate a physical medium, not just a colour scheme.** Alongside the existing e-ink and CRT looks, MandoCode Desktop now ships a monochrome amber terminal, a vacuum-fluorescent panel, a vector scope, a passive-matrix LCD, a Solari split-flap board, a cyanotype blueprint, a diff --git a/docs/agent-mentions.md b/docs/agent-mentions.md new file mode 100644 index 0000000..0f5f4be --- /dev/null +++ b/docs/agent-mentions.md @@ -0,0 +1,274 @@ +# Agent mentions — design + +Status: **built**, except `review_agent_work`. See the build order below. + +## What it is + +Typing `@Knuckles` in one agent's input addresses another open agent. The addressed agent can be +asked what it is doing, have its work reviewed, have its transcript read, or — at the far end — be +asked a genuine question that costs it a turn. + +## The insight the design turns on + +The obvious implementation is "pull Knuckles' transcript into Sonic's context." That is the wrong +default, for two reasons: + +1. **It is the expensive option.** A transcript is thousands of tokens, copied into Sonic's window + and paid for on every subsequent turn of Sonic's conversation. Asking Knuckles a question costs + one turn and returns a few hundred tokens of *answer*. +2. **It is the lossy option.** Sonic has to work out which parts of a long transcript matter. + Knuckles already knows. + +So the transcript is the *escalation*, not the starting point. + +The second insight is that **most of what you want does not require waking the other agent at all**: + +| Tool | Wakes the target's model | Works while target is busy | Answers | +|---|---|---|---| +| `agent_status` | no | **yes** | "are you done", "what are you on" | +| `review_agent_work` | no | **yes** | "is the work any good" | +| `read_agent_transcript` | no | **yes** | "what exactly was said" | +| `ask_agent` | **yes** | no | judgment, explanation, intent | + +Three of four are pure observation. They carry no loop risk, no concurrency problem and no approval +question, because nothing runs. All the hazard is concentrated in the fourth. + +## Build order + +Each step is useful alone and complexity rises monotonically. Stopping after any of them leaves a +coherent feature. + +1. `agent_status` — **built**, as `list_agents` and `get_agent_status` +2. `review_agent_work` — still proposed +3. `read_agent_transcript` — **built** +4. `ask_agent` — **built** + +## The tools + +Registered per agent through `AIService.SetHostTools`, the same seam the browser tools use +(`AgentSession` already calls it with `AIFunctionFactory.Create(...)` for each preview tool). The +host owns them, so they can see every open session while each agent's own file tools stay bounded to +its own root. + +### 1. `agent_status(name)` + +Returns host-observable facts. No model call anywhere. + +Everything needed already exists: + +- `BusyStateService.IsBusy` — working or idle +- `ChatController.PlanProgressChanged` — already emits `CurrentStep` / `TotalSteps` +- `AgentCommandLog.IsRunning` and its scrollback — the commands that agent has run +- the session's project root, model and title + +This is the tool that makes "have you finished the X task?" answerable *at the moment you would +actually ask it* — which is while the other agent is still working. An earlier draft of this design +had the busy case refuse; that would have failed the feature's most common question. + +### 2. `review_agent_work(name)` + +Returns the target's working-tree diff so the *asking* agent can form its own judgment. + +Self-assessment is the weakest form of review — asking an agent whether its own work is good is +nearly worthless. Handing the reviewer the artifact is a real review. + +The artifact already exists: `GitQuickStatus` yields `GitChangeEntry` (path + change kind) and +`GitFileDiff` (parsed diff lines, with a `Truncated` flag for oversized diffs). Pair it with the +target's `AgentCommandLog` and the reviewer sees both what changed and what was run to produce it. + +**Torn reads.** A working tree that is actively being written gives a half-finished picture. The +mitigation is already in hand: this tool checks `IsBusy` first and prefixes its result with a +warning when the target is mid-turn, rather than silently returning a diff that is about to change. + +### 3. `read_agent_transcript(name, ...)` + +The escalation. Expensive and explicit, reached for when an answer or a diff was not enough. +`IAiService.ExportHistoryJson()` already serialises a conversation. + +The model chooses between this and `ask_agent` on its own — describing both honestly is what +implements "pull the transcript only when more context is needed." There is no condition to detect. + +### 4. `ask_agent(name, question)` + +Runs a real turn on the target's model and returns its answer. + +`AIService.ChatStreamWithHostInstructionAsync` is the right primitive. Its guidance is carried as +"a real, transient system-role message… available for this turn and its continuations but removed +afterward, so it cannot masquerade as user-authored text or affect later turns" — exactly what a +delegated turn needs. Knuckles is told "Sonic is asking you this" for one turn only, with no +contamination of its own later conversation. + +Open problems, all of which belong to this tool alone: + +- **Loops — settled: `AgentCallChain`, an AsyncLocal chain with two limits.** A key set catches a + true cycle (an agent already in this chain being asked again); a depth cap of 2 catches a chain + that never repeats anyone but keeps going. AsyncLocal rather than a field, because the chain + belongs to one call sequence — two conversations asking questions at once must not consume each + other's budget. +- **Concurrency — settled: refuse, and route the caller to the read tools.** If the target is + mid-turn, `ask_agent` returns its status plus a pointer to `read_agent_transcript`, so the busy + case degrades to reading rather than dead-ending. This matters because busy is the COMMON case: + "have you finished X?" is asked precisely when the answer might be no. Queueing was rejected — a + caller that waited would stall its own turn behind work of unknown length. +- **Acting vs answering — settled: a delegated turn is a FULL turn.** The target answers with all + of its own tools, exactly as it would answer the user, because the point of asking a colleague is + that they can go and look. A read-only delegated turn would make the feature useless for what it + is for. + + The containment is that the target's own approval gates still stand, and they raise their dialogs + in the target's own tab, where the user can see who is being asked to do what. The host + instruction that frames the turn ("another agent is asking you this") is a *framing, not a + sandbox* — a model can wander past prompt-level guidance, so it is not relied on for safety. + +## The `@` picker + +`ChatTabView.Input.cs` already implements `@` for project files: it walks back from the caret to the +token start, and if the token opens with `@` it filters through `FileAutocompleteProvider` and calls +`ShowSuggestions(SuggestMode.File, ...)`. + +Agents join that same picker and **rank above files**. Rationale: + +- Open agents are a small, closed, known set; project files are thousands. A short list on top costs + the file case almost nothing. +- Callsigns are capitalised single words, so genuine collisions with real filenames are rare, and + the picker disambiguates the rare ones visually. +- `@` already means "a participant" to anyone who has used Slack. A second sigil would be a thing to + learn for no benefit. + +Implementation notes: + +- A new `SuggestMode.Agent`, because `AcceptSuggestion` branches on the mode and an agent mention + substitutes differently from a file path — no trailing `/` drill-in behaviour, and the accepted + text should be the callsign, not a path. +- The agent's own tab must be excluded from its own picker. +- Suggestion rows want a distinguishing glyph and a subtitle (folder name, or busy/idle), so an + agent row never reads as a file row. +- **The host must also learn about mentions, not just the picker.** An earlier draft of this doc + claimed the picker was an affordance and no host-side parsing was needed. That was wrong, and + testing found it immediately: `ChatController.ProcessFileReferences` expands every `@token` at + send time and warns `Couldn't find the referenced file or folder: Ninja` when the lookup misses. + A mention never reached the model at all. Agent names must be resolved *before* the file lookup + and skipped by it. +- Agents win over files on a name clash. A callsign is a deliberate act of addressing someone; a + same-named file is a coincidence, and that file stays reachable by any path carrying a separator + or an extension. + +## Who am I talking to + +A receiving agent must be able to tell a relayed question from something the user typed, and it must +be able to tell *structurally* rather than by reading the content. + +The first implementation put the attribution only in the host instruction. That was not enough, and +testing showed why within minutes: the instruction is transient by design — the engine removes it +once the turn ends — so the peer's question stayed in the receiving agent's history as an ordinary +user turn. Asked afterwards who it had been talking to, the agent answered that the last message +"claimed" to be from another agent. It was reasoning from content, because content was all it had. + +Worse, one agent told another "the person you're talking to is Mando", and the receiver had no way +to weigh that. A claim inside a relayed message had the same standing as a fact. + +So: + +- **Every agent-to-agent message is wrapped in a host-applied envelope** (`PeerMessageEnvelope`) + naming the sender and stating it is not from the user. This is part of the message, so it persists + in history rather than evaporating with the turn. +- **A message without an envelope is from the user.** That is the rule the framing states, and it is + the only rule needed, because the host is the only thing that can add one. +- **`Wrap` strips any envelope already present in the payload.** Without that, "only MandoCode adds + this" would be a claim the code did not keep — one agent could relay a message that appeared to + come from a third, and an agent innocently quoting the marker while discussing this feature would + produce the same confusion. +- **Claims inside a relayed message are that agent's assertions, not facts.** The framing says so + explicitly, including claims about who the user is. + +## Visibility + +An exchange between agents appears in **both** transcripts — the target's tab showing that it was +asked, and by whom. That is the audit trail, and it also makes the feature legible: you can watch +your agents talk instead of wondering what they said. + +`agent_status`, `review_agent_work` and `read_agent_transcript` are reads and need no entry in the +target's transcript; `ask_agent` produces a real turn there and must be attributed. + +## Delegation, the inbox, and the rolling digest + +`ask_agent` blocks. For a question that is right; for a job — "build a website" — it holds the +asking agent's turn gate for minutes, and messages to that agent are dropped while it waits. Two +separate problems: the asker is locked, and agents cannot wake up to report anything. + +**`delegate_to_agent`** returns immediately. The job runs in the background on the target, and the +asking agent's turn ends at once, so the user keeps their agent. + +**Each agent has an inbox** (`AgentInbox`). Anything that happens while an agent is idle waits +there and is folded into the preamble of its next turn — the same ride-along `_armedContexts` +already uses for imported snapshots. Agents are turn-based, so this is the only moment an idle +agent can take delivery of anything. + +**Progress is a rolling digest, not an event log.** This is the load-bearing decision. A delegation +posts under ONE inbox id for its whole life, so each report REPLACES the last: a job that runs for +ten minutes costs the same context as one that runs for ten seconds. An append-only feed would grow +with the other agent's work and be paid for on every subsequent turn — the transcript-copying +problem arriving in instalments. + +**The digest is assembled, never written by a model.** Every field comes from state the host already +keeps: the turn gate, plan progress, `AgentCommandLog`, the directory entry. So keeping it current +costs string formatting rather than a turn, it is always accurate, and it cannot invent progress. A +model-written précis would cost a call per update and could report a job as nearly done because it +read that way. + +**Notification and knowing are separate, and each is cheap.** A finished job appends a card to the +*delegating* agent's transcript — that is the "tell me when it's done", and it costs no model turn +at all. The agent itself learns from its inbox on its next turn. Neither requires waking anything. + +Rejected along the way: + +- **A publish/subscribe broker.** It answers "who gets the message", but the real blocker is that an + idle agent cannot act on one — so a broker would sit on top of the same two delivery mechanisms + and leave the original problem intact. The routing is also already known: A asked B. +- **Streaming B's actions to A.** The progressive idea was right; the delivery was not. Raw actions + are a firehose, and "A unsubscribes when it knows enough" cannot work — A only decides while + running, so the feed would accumulate unbounded until the user next happened to speak to it. +- **Queueing a job for a busy agent.** It would report work as accepted while nothing had started. + Refusing names the reason and the current state instead. + +## Deliberately out of scope + +- Cross-agent *delegation* ("Knuckles, go fix the auth module"). Blocked on the approval question + above, and worth having mentions in hand before deciding it. +- Mentioning a closed agent or a saved session. Snapshots already cover recovering an old + conversation as context. +- Agents mentioning each other unprompted. Every cross-agent call in this design begins with + something the user typed. + +## Future: reaching Desktop agents from the CLI + +Investigated, deliberately not built. Recorded because the findings are the expensive part. + +- **`read_agent_transcript` is already cross-process.** It reads `ConversationLog.Load(key)` from + disk, not from memory, so a separate process could read a Desktop agent's conversation today if it + knew the key. +- **Discovery is the only missing piece for a read-only bridge.** `RefreshAgentDirectory` already + runs on every tab-strip refresh; writing that snapshot to a file would give a CLI `@`-completion, + status, and transcripts with no IPC and no protocol. It needs a PID and a heartbeat, or the CLI + would confidently list agents belonging to a Desktop that has since exited. The two apps also use + different roots today (`LocalApplicationData/MandoCode.Desktop` versus `~/.mandocode`), which is an + agreement rather than an obstacle. +- **Agent Framework does not hand you remoting, but it does not fight it.** The packages in use + (`Microsoft.Agents.AI` / `.Workflows`) expose no remote, host, proxy or transport types — the + surface is entirely in-process. What they *do* expose is `AIAgent.DeserializeSessionAsync` with a + serializable `AgentSession`, so conversation state already has a wire format; and + `DelegatingAIAgent`, which is exactly the seam for a proxy that forwards `RunCoreAsync` over IPC. + A remote agent would satisfy `IAgentPeer` and none of the four tools would know the difference. +- **Every hard problem here is product-shaped, not framework-shaped.** Where does an approval dialog + appear when the CLI makes a Desktop agent write a file? How does a local endpoint prove the caller + is the user rather than any process on the machine? Where does a completion go when the CLI has + exited mid-job? And the addressing is asymmetric: Desktop has N named agents, the CLI is one + unnamed conversation. A transport answers none of these. + +## The one boundary this widens + +Every agent's file access is bounded to its own `ProjectRootAccessor` by design. +`review_agent_work` deliberately reaches past that so the reviewer can see the target's folder. It +is read-only and scoped to another *open agent's* root, never to arbitrary paths — but it is a real +widening of what an agent can see, and it should be a decision rather than a discovery. If both +agents share a root, nothing is crossed at all. diff --git a/src/MandoCode.Desktop.Tests/AgentAskTests.cs b/src/MandoCode.Desktop.Tests/AgentAskTests.cs new file mode 100644 index 0000000..739ce62 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentAskTests.cs @@ -0,0 +1,311 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Asking another agent a real question. This is the only cross-agent tool that runs a turn on +/// someone else's model, so it is the only one that can loop, collide with a busy agent, or spend +/// tokens the user did not ask for — everything here is about those three. +/// +public class AgentAskTests +{ + private const string Self = "self-key"; + + private sealed class FakePeer : IAgentPeer + { + public FakePeer(string key, string answer = "done both", bool busy = false) + { + Key = key; Answer = answer; IsBusy = busy; + } + public string Key { get; } + public string Answer { get; } + public bool IsBusy { get; set; } + public int Asked { get; private set; } + public string? LastAskedBy { get; private set; } + public string? LastQuestion { get; private set; } + + public Task AskAsync(string askedBy, string question, CancellationToken cancellationToken = default) + { + Asked++; LastAskedBy = askedBy; LastQuestion = question; + // Mirrors the real peer: it claims itself and reports back, rather than the caller + // deciding from a flag it read a moment earlier. + return Task.FromResult(IsBusy ? PeerAnswer.Busy() : PeerAnswer.Ok(Answer)); + } + } + + private static AgentEntry Entry(string key, string name, bool busy = false, int step = 0, int total = 0) => + new(key, name, $@"C:\src\{name}", name.ToLowerInvariant(), "qwen3:8b", busy, false, step, total); + + private static (AgentDirectoryTools Tools, FakePeer Peer, AgentDirectory Dir) Setup( + bool busy = false, string answer = "done both") + { + var dir = new AgentDirectory(); + dir.Replace(new[] { Entry("k1", "Ninja", busy, step: 4, total: 9), Entry(Self, "Sonic") }); + var peer = new FakePeer("k1", answer, busy); + dir.RegisterPeer(peer); + return (new AgentDirectoryTools(dir, Self, () => "Sonic"), peer, dir); + } + + [Fact] + public async Task DelegatingReturnsImmediatelyWithoutWaitingForTheJob() + { + // The point of the whole feature: the delegating agent's turn ends at once, so the user is + // not locked out of it while a long job runs. + var dir = new AgentDirectory(); + dir.Replace(new[] { Entry("k1", "Ninja"), Entry(Self, "Sonic") }); + var peer = new FakePeer("k1"); + dir.RegisterPeer(peer); + Delegation? started = null; + var tools = new AgentDirectoryTools(dir, Self, () => "Sonic", (d, _) => started = d); + + var result = tools.DelegateToAgent("Ninja", "build the marketing site"); + + Assert.NotNull(started); + Assert.Equal("build the marketing site", started!.Task); + Assert.Equal(0, peer.Asked); // nothing awaited here + Assert.Contains("You will be told when it finishes", result); + await Task.CompletedTask; + } + + [Fact] + public async Task ABusyAgentIsNotGivenAJobToSitOn() + { + // Queuing would tell the user their work was accepted while nothing had started, and hide + // it behind work of unknown length. + var dir = new AgentDirectory(); + dir.Replace(new[] { Entry("k1", "Ninja", busy: true, step: 4, total: 9), Entry(Self, "Sonic") }); + dir.RegisterPeer(new FakePeer("k1", busy: true)); + var startedAnything = false; + var tools = new AgentDirectoryTools(dir, Self, () => "Sonic", (_, _) => startedAnything = true); + + var result = tools.DelegateToAgent("Ninja", "build it"); + + Assert.False(startedAnything); + Assert.Contains("busy", result); + Assert.Contains("step 4 of 9", result); + await Task.CompletedTask; + } + + [Fact] + public async Task CheckingDelegationsReportsEachJobWithoutAskingAnyone() + { + // This is what answers "how's the site coming?" while the other agent is mid-build — no + // turn on either side, and it works precisely because the target is busy. + var dir = new AgentDirectory(); + dir.Replace(new[] { Entry("k1", "Ninja", busy: true, step: 2, total: 5), Entry(Self, "Sonic") }); + dir.RegisterPeer(new FakePeer("k1")); + var tools = new AgentDirectoryTools(dir, Self, () => "Sonic", (_, _) => { }); + + tools.DelegateToAgent("Ninja", "build the site"); + var status = tools.CheckDelegations(); + + Assert.Contains("build the site", status); + Assert.Contains("step 2 of 5", status); + await Task.CompletedTask; + } + + [Fact] + public async Task WithNothingDelegatedTheReportSaysSoPlainly() + { + var (tools, _, _) = Setup(); + Assert.Contains("not handed any work", tools.CheckDelegations()); + await Task.CompletedTask; + } + + [Fact] + public async Task AnIdleAgentIsAskedAndItsAnswerComesBack() + { + var (tools, peer, _) = Setup(answer: "yes, both finished"); + + var result = await tools.AskAgent("Ninja", "have you finished the X and Y tasks?"); + + Assert.Equal(1, peer.Asked); + Assert.Equal("have you finished the X and Y tasks?", peer.LastQuestion); + Assert.Contains("yes, both finished", result); + Assert.Contains("Ninja replied", result); + } + + [Fact] + public async Task TheAnswerIsAttributedToTheAgentDoingTheAsking() + { + // The target shows this in its own transcript. Unattributed, a question would read as + // something the user typed. + var (tools, peer, _) = Setup(); + + await tools.AskAgent("Ninja", "status?"); + + Assert.Equal("Sonic", peer.LastAskedBy); + } + + [Fact] + public async Task ABusyAgentDeclinesAndTheCallerIsPointedAtTheReadTools() + { + // The busy case is the COMMON case — "have you finished" is asked precisely when the answer + // might be no. A bare refusal would end the line of enquiry; naming the fallback keeps it + // going with what can be read without interrupting anyone. + // + // Note the peer IS asked: the claim is what decides, not a flag read beforehand. Checking + // first left a window where the agent could take a turn in between. + var (tools, peer, _) = Setup(busy: true); + + var result = await tools.AskAgent("Ninja", "done yet?"); + + Assert.Equal(1, peer.Asked); + Assert.Contains("could not answer", result); + Assert.Contains("step 4 of 9", result); + Assert.Contains("read_agent_transcript", result); + } + + [Fact] + public async Task AnAgentThatBecomesBusyBetweenTheHintAndTheAskStillDeclinesCleanly() + { + // The race the atomic claim exists for: the directory says idle, and the agent takes a turn + // before the question lands. The far side must decline rather than run a second turn on the + // same chat history, and the caller must still get the useful refusal. + var (tools, peer, _) = Setup(busy: false); + peer.IsBusy = true; // as if a turn started in the gap + + var result = await tools.AskAgent("Ninja", "done yet?"); + + Assert.Contains("could not answer", result); + Assert.Contains("read_agent_transcript", result); + } + + [Fact] + public async Task AnUnknownAgentNamesTheOnesThatExist() + { + var (tools, peer, _) = Setup(); + + var result = await tools.AskAgent("Knuckles", "hello?"); + + Assert.Equal(0, peer.Asked); + Assert.Contains("Ninja", result); + } + + [Fact] + public async Task AskingYourselfIsRefused() + { + var (tools, peer, _) = Setup(); + Assert.Contains("is you", await tools.AskAgent("Sonic", "what am I doing?")); + Assert.Equal(0, peer.Asked); + } + + [Fact] + public async Task AClosedTabCannotBeAsked() + { + // The display snapshot can still name an agent whose tab has gone. Resolving must not + // produce a reference to something that no longer exists. + var (tools, peer, dir) = Setup(); + dir.RemovePeer("k1"); + + var result = await tools.AskAgent("Ninja", "still there?"); + + Assert.Equal(0, peer.Asked); + Assert.Contains("closed", result); + } + + [Fact] + public async Task AnAgentAlreadyInTheChainIsNotAskedAgain() + { + // Sonic asks Ninja; while answering, Ninja asks Sonic back. Without the guard the two + // continue until the budget is gone. + var (tools, peer, _) = Setup(); + + using (AgentCallChain.Enter("k1")) + { + var result = await tools.AskAgent("Ninja", "and you?"); + Assert.Equal(0, peer.Asked); + Assert.Contains("loop", result); + } + } + + [Fact] + public async Task AChainStopsAtTheDepthLimitEvenWithoutRepeatingAnAgent() + { + // A → B → C → D never repeats anyone, so the cycle check alone would let it run as far as + // there are agents. The depth cap is what bounds the cost. + var (tools, peer, _) = Setup(); + + using (AgentCallChain.Enter("other-1")) + using (AgentCallChain.Enter("other-2")) + { + var result = await tools.AskAgent("Ninja", "one more?"); + Assert.Equal(0, peer.Asked); + Assert.Contains("limit", result); + } + } + + [Fact] + public async Task TheGuardSurvivesTheAwaitsAndThreadHopsOfARealCall() + { + // The chain is carried in an AsyncLocal, and the check happens deep inside the engine's + // tool-invocation machinery rather than next to the Enter() that set it. If execution + // context did not flow across those boundaries the guard would stop guarding SILENTLY — + // no exception, no failing test, just two agents talking until the budget is gone. + // + // So this deliberately crosses the boundaries a real call crosses: an await, a thread-pool + // hop, and a continuation that does not capture context. + var (tools, peer, _) = Setup(); + + using (AgentCallChain.Enter("k1")) + { + await Task.Yield(); + await Task.Run(async () => + { + await Task.Delay(1).ConfigureAwait(false); + var result = await tools.AskAgent("Ninja", "still looping?").ConfigureAwait(false); + Assert.Contains("loop", result); + }).ConfigureAwait(false); + } + + Assert.Equal(0, peer.Asked); + } + + [Fact] + public async Task TwoChainsRunningAtOnceDoNotSpendEachOthersBudget() + { + // Two conversations can ask questions at the same moment. A shared field would let one + // chain's depth block the other's first question — the reason this is an AsyncLocal and not + // a static counter. Verified by running both concurrently rather than in sequence. + var (tools, peer, _) = Setup(); + + var blocked = Task.Run(async () => + { + using (AgentCallChain.Enter("other-1")) + using (AgentCallChain.Enter("other-2")) + { + await Task.Delay(5).ConfigureAwait(false); + return await tools.AskAgent("Ninja", "deep chain").ConfigureAwait(false); + } + }); + + var allowed = Task.Run(async () => + { + await Task.Delay(5).ConfigureAwait(false); + return await tools.AskAgent("Ninja", "fresh chain").ConfigureAwait(false); + }); + + var results = await Task.WhenAll(blocked, allowed); + + Assert.Contains("limit", results[0]); // the deep chain is stopped + Assert.Contains("Ninja replied", results[1]); // the independent one is not + Assert.Equal(1, peer.Asked); + } + + [Fact] + public async Task TheChainUnwindsSoLaterQuestionsAreNotBlocked() + { + // The guard is scoped to one chain. A question asked after an earlier chain finished must + // start from a clean budget, or the first cross-agent call of a session would poison the rest. + var (tools, peer, _) = Setup(); + + using (AgentCallChain.Enter("other-1")) + using (AgentCallChain.Enter("other-2")) { } + + var result = await tools.AskAgent("Ninja", "now?"); + + Assert.Equal(1, peer.Asked); + Assert.Contains("Ninja replied", result); + } +} diff --git a/src/MandoCode.Desktop.Tests/AgentDirectoryTests.cs b/src/MandoCode.Desktop.Tests/AgentDirectoryTests.cs new file mode 100644 index 0000000..b36ceeb --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentDirectoryTests.cs @@ -0,0 +1,104 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// The register behind '@' mentions. The behaviour that matters is resolution: a mention is the user +/// naming a specific colleague, so guessing wrong is worse than not resolving at all. +/// +public class AgentDirectoryTests +{ + private static AgentEntry Agent(string key, string name, bool busy = false, int step = 0, int total = 0) => + new(key, name, $@"C:\src\{name}", name.ToLowerInvariant(), "qwen3:8b", busy, false, step, total); + + private static AgentDirectory With(params AgentEntry[] agents) + { + var d = new AgentDirectory(); + d.Replace(agents); + return d; + } + + [Fact] + public void ResolvesACallsignRegardlessOfCase() + { + var d = With(Agent("k1", "Ninja")); + Assert.Equal("k1", d.Resolve("ninja")?.Key); + Assert.Equal("k1", d.Resolve("NINJA")?.Key); + } + + [Fact] + public void AnExactNameIsNeverShadowedByALongerOne() + { + // "Ninja" and "NinjaTwo" both start with "Ninja". Addressing Ninja must reach Ninja. + var d = With(Agent("k1", "Ninja"), Agent("k2", "NinjaTwo")); + Assert.Equal("k1", d.Resolve("Ninja")?.Key); + } + + [Fact] + public void AnAmbiguousPrefixResolvesToNothing() + { + // Two candidates and no exact match: refusing is right. Picking one would silently send a + // question to an agent the user did not name. + var d = With(Agent("k1", "Ninja"), Agent("k2", "Nitro")); + Assert.Null(d.Resolve("Ni")); + } + + [Fact] + public void AnUnambiguousPrefixResolves() + { + var d = With(Agent("k1", "Ninja"), Agent("k2", "Falchion")); + Assert.Equal("k1", d.Resolve("Nin")?.Key); + } + + [Fact] + public void AnUnknownNameResolvesToNothing() + { + Assert.Null(With(Agent("k1", "Ninja")).Resolve("Sonic")); + Assert.Null(With(Agent("k1", "Ninja")).Resolve("")); + } + + [Fact] + public void ThePickerExcludesTheAgentDoingTheTyping() + { + // An agent mentioning itself is never what was meant, and offering it invites the confusion. + var d = With(Agent("self", "Ninja"), Agent("other", "Falchion")); + var matches = d.Match("", excludeKey: "self"); + Assert.Equal(new[] { "Falchion" }, matches.Select(a => a.Name)); + } + + [Fact] + public void ThePickerRanksPrefixMatchesFirst() + { + // Typing "ni" means you are probably reaching for Ninja, not for the agent that merely + // contains those letters. + var d = With(Agent("k1", "Hornight"), Agent("k2", "Ninja")); + var matches = d.Match("ni", excludeKey: null); + Assert.Equal(new[] { "Ninja", "Hornight" }, matches.Select(a => a.Name)); + } + + [Fact] + public void DescribeLeadsWithWhetherTheAgentIsActuallyWorking() + { + // This is the sentence that answers "have you finished yet", so busy versus idle has to be + // unmissable rather than inferred from surrounding detail. + var busy = AgentDirectory.Describe(Agent("k1", "Ninja", busy: true, step: 3, total: 7)); + var idle = AgentDirectory.Describe(Agent("k2", "Falchion")); + + Assert.Contains("WORKING", busy); + Assert.Contains("step 3 of 7", busy); + Assert.Contains("IDLE", idle); + Assert.DoesNotContain("step", idle); + } + + [Fact] + public void SnapshotsDoNotChangeUnderTheCaller() + { + // Tools read the register on model-loop threads while the UI republishes it. A caller that + // took a list must keep the list it took. + var d = With(Agent("k1", "Ninja")); + var taken = d.All; + d.Replace(new[] { Agent("k2", "Falchion") }); + Assert.Equal(new[] { "Ninja" }, taken.Select(a => a.Name)); + } +} diff --git a/src/MandoCode.Desktop.Tests/AgentDirectoryToolsTests.cs b/src/MandoCode.Desktop.Tests/AgentDirectoryToolsTests.cs new file mode 100644 index 0000000..8d9a04d --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentDirectoryToolsTests.cs @@ -0,0 +1,98 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// What the model actually receives when it asks about another agent. These are read tools, so the +/// bar is that a wrong or missing name produces something the model can act on rather than a dead +/// end — a failed lookup should tell it who IS open. +/// +public class AgentDirectoryToolsTests +{ + private const string Self = "self-key"; + + private static AgentEntry Agent(string key, string name, bool busy = false, bool cmd = false, + int step = 0, int total = 0) => + new(key, name, $@"C:\src\{name}", name.ToLowerInvariant(), "qwen3:8b", busy, cmd, step, total); + + private static (AgentDirectoryTools Tools, AgentDirectory Dir) Setup(params AgentEntry[] agents) + { + var d = new AgentDirectory(); + d.Replace(agents.Append(Agent(Self, "Sonic"))); + return (new AgentDirectoryTools(d, Self), d); + } + + [Fact] + public void StatusReportsWorkingWithPlanPosition() + { + var (tools, _) = Setup(Agent("k1", "Ninja", busy: true, cmd: true, step: 5, total: 6)); + + var status = tools.GetAgentStatus("Ninja"); + + Assert.Contains("WORKING", status); + Assert.Contains("step 5 of 6", status); + Assert.Contains("shell command is running", status); + } + + [Fact] + public void StatusAnswersTheQuestionEvenWhileTheAgentIsMidTurn() + { + // The whole point: "have you finished X?" is asked precisely when the answer might be no. + // This tool reads state, so a busy agent is an answer rather than a refusal. + var (tools, _) = Setup(Agent("k1", "Ninja", busy: true)); + + var status = tools.GetAgentStatus("Ninja"); + + Assert.DoesNotContain("busy", status, StringComparison.OrdinalIgnoreCase); + Assert.Contains("WORKING", status); + } + + [Fact] + public void AnUnknownNameNamesTheAgentsThatDoExist() + { + // A bare "not found" leaves the model guessing. Listing the real names lets it recover in + // the same turn, which is usually a typo or a half-remembered callsign. + var (tools, _) = Setup(Agent("k1", "Ninja"), Agent("k2", "Falchion")); + + var status = tools.GetAgentStatus("Knuckles"); + + Assert.Contains("No agent named \"Knuckles\"", status); + Assert.Contains("Ninja", status); + Assert.Contains("Falchion", status); + } + + [Fact] + public void AskingAboutYourselfSaysSoRatherThanReportingNothing() + { + var (tools, _) = Setup(); + + var status = tools.GetAgentStatus("Sonic"); + + Assert.Contains("this agent", status); + } + + [Fact] + public void ListingExcludesTheAskerAndMarksWhoIsWorking() + { + var (tools, _) = Setup(Agent("k1", "Ninja", busy: true), Agent("k2", "Falchion")); + + var list = tools.ListAgents(); + + Assert.DoesNotContain("Sonic", list); + Assert.Contains("Ninja", list); + Assert.Contains("(working)", list); + Assert.Contains("(idle)", list); + } + + [Fact] + public void ASoleAgentIsToldItIsAlone() + { + // Distinct from "not found": there is nobody to mention, so the model should stop looking + // rather than try another spelling. + var (tools, _) = Setup(); + + Assert.Contains("No other agents are open", tools.ListAgents()); + Assert.Contains("no other agents open at all", tools.GetAgentStatus("Ninja")); + } +} diff --git a/src/MandoCode.Desktop.Tests/ConversationRoleTests.cs b/src/MandoCode.Desktop.Tests/ConversationRoleTests.cs new file mode 100644 index 0000000..7a407c8 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/ConversationRoleTests.cs @@ -0,0 +1,76 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Who said what, after the app has been closed and reopened. +/// +/// The bug these cover: a question from another agent was never written to the conversation +/// log at all. The log held the ANSWER with nothing before it, so a restored session re-briefed the +/// model with a dangling reply, and read_agent_transcript showed answers with nothing that had +/// prompted them. +/// +public class ConversationRoleTests +{ + [Fact] + public void AnAgentTurnIsLabelledAsAnAgentNotAsTheUser() + { + // Replaying a relayed question as "User:" after a restart is exactly the confusion the + // envelope exists to prevent — and a restart is the one place a transient host instruction + // could never reach. + Assert.Equal("Another agent", ConversationLog.RoleLabel(ConversationLog.AgentRole)); + Assert.Equal("User", ConversationLog.RoleLabel("u")); + Assert.Equal("Assistant", ConversationLog.RoleLabel("a")); + } + + [Fact] + public void AnUnknownRoleFallsBackToAssistantRatherThanVanishing() + { + // Logs written by an older build carry roles this one has never seen. Rendering something + // is better than dropping a turn out of the replay. + Assert.Equal("Assistant", ConversationLog.RoleLabel("?")); + Assert.Equal("Assistant", ConversationLog.RoleLabel("")); + } + + [Fact] + public void ARelayedQuestionSurvivesARoundTripThroughTheLog() + { + // The full path a restart takes: wrap, persist, reload, relabel. The envelope has to still + // be legible at the end of it. + var key = "roundtrip-" + Guid.NewGuid().ToString("N"); + try + { + var envelope = PeerMessageEnvelope.Wrap("Ninja", "have you finished the X task?"); + ConversationLog.Append(key, ConversationLog.AgentRole, envelope); + ConversationLog.Append(key, "a", "yes, both are done"); + + var turns = ConversationLog.Load(key); + + Assert.Equal(2, turns.Count); + Assert.Equal(ConversationLog.AgentRole, turns[0].R); + Assert.Contains("sent by the agent \"Ninja\"", turns[0].T); + Assert.Contains("NOT from the user", turns[0].T); + Assert.Equal("Another agent", ConversationLog.RoleLabel(turns[0].R)); + } + finally { ConversationLog.Delete(key); } + } + + [Fact] + public void AnAnswerIsNoLongerLeftWithoutTheQuestionThatPromptedIt() + { + // The shape of the original bug, asserted directly: an assistant turn with no preceding + // turn is what a restored session used to re-brief the model with. + var key = "paired-" + Guid.NewGuid().ToString("N"); + try + { + ConversationLog.Append(key, ConversationLog.AgentRole, PeerMessageEnvelope.Wrap("Ninja", "status?")); + ConversationLog.Append(key, "a", "still building"); + + var turns = ConversationLog.Load(key); + + Assert.NotEqual("a", turns[0].R); // something precedes the answer + } + finally { ConversationLog.Delete(key); } + } +} diff --git a/src/MandoCode.Desktop.Tests/DelegationTests.cs b/src/MandoCode.Desktop.Tests/DelegationTests.cs new file mode 100644 index 0000000..a042d70 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/DelegationTests.cs @@ -0,0 +1,253 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Handing a job to another agent without blocking on it. The design rests on one property — the +/// progress report is O(1) however long the job runs — so that is what most of these check. +/// +public class DelegationTests +{ + private static AgentEntry Entry(string key, string name, bool busy = true, bool cmd = false, + int step = 0, int total = 0) => + new(key, name, $@"C:\src\{name}", name.ToLowerInvariant(), "qwen3:8b", busy, cmd, step, total); + + private static (DelegationRegistry Reg, Delegation D) Open() => + (new DelegationRegistry(), new DelegationRegistry().Open("a", "Sonic", "b", "Ninja", "build the site")); + + // ---- The inbox -------------------------------------------------------------- + + [Fact] + public void PostingTheSameIdReplacesRatherThanAccumulates() + { + // The whole cost argument. Ten minutes of progress reports must cost the same as one. + var inbox = new AgentInbox(); + + for (int i = 1; i <= 200; i++) + inbox.Post(new InboxMessage("delegation:d1", "Ninja — working", $"step {i} of 200", DateTimeOffset.Now)); + + var messages = inbox.Peek(); + Assert.Single(messages); + Assert.Contains("step 200 of 200", messages[0].Body); + } + + [Fact] + public void DistinctMessagesAreCappedSoAProducerCannotGrowItWithoutBound() + { + var inbox = new AgentInbox(); + for (int i = 0; i < AgentInbox.MaxMessages + 10; i++) + inbox.Post(new InboxMessage($"m{i}", "s", "b", DateTimeOffset.Now)); + + Assert.Equal(AgentInbox.MaxMessages, inbox.Peek().Count); + // Oldest dropped first: a stale report matters less than the newest event. + Assert.DoesNotContain(inbox.Peek(), m => m.Id == "m0"); + Assert.Contains(inbox.Peek(), m => m.Id == $"m{AgentInbox.MaxMessages + 9}"); + } + + [Fact] + public void DrainingEmptiesTheMailboxSoNothingIsDeliveredTwice() + { + var inbox = new AgentInbox(); + inbox.Post(new InboxMessage("m1", "s", "b", DateTimeOffset.Now)); + + Assert.Single(inbox.Drain()); + Assert.Empty(inbox.Drain()); + Assert.False(inbox.HasMessages); + } + + [Fact] + public void TheDeliveredTextIsFramedAsSomethingThatHappenedNotSomethingSaid() + { + // Otherwise a delegation report reads as the user speaking, which is the same confusion the + // agent-message envelope exists to prevent. + var text = AgentInbox.Format(new[] + { + new InboxMessage("m1", "Ninja — finished", "the site is built", DateTimeOffset.Now) + }); + + Assert.Contains("While you were away", text); + Assert.Contains("Background you now have", text); + Assert.Contains("the site is built", text); + } + + [Fact] + public void AnEmptyMailboxFormatsToNothingAtAll() + { + // A turn with no news must not carry an empty header into the model's context. + Assert.Equal("", AgentInbox.Format(Array.Empty())); + } + + // ---- The digest ------------------------------------------------------------- + + [Fact] + public void AProgressDigestSaysWhatTheOtherAgentIsActuallyDoing() + { + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Sonic", "b", "Ninja", "build the marketing site"); + + var digest = DelegationRegistry.Digest( + d, Entry("b", "Ninja", cmd: true, step: 4, total: 7), new[] { "npm install", "npm run build" }); + + Assert.Contains("build the marketing site", digest.Body); + Assert.Contains("step 4 of 7", digest.Body); + Assert.Contains("command is running", digest.Body); + Assert.Contains("npm run build", digest.Body); + } + + [Fact] + public void EveryProgressDigestSharesOneInboxIdSoItOverwrites() + { + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Sonic", "b", "Ninja", "build it"); + + var early = DelegationRegistry.Digest(d, Entry("b", "Ninja", step: 1, total: 7), Array.Empty()); + var later = DelegationRegistry.Digest(d, Entry("b", "Ninja", step: 6, total: 7), Array.Empty()); + + Assert.Equal(early.Id, later.Id); + } + + [Fact] + public void AFinishedDigestCarriesTheResult() + { + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Sonic", "b", "Ninja", "build it"); + var done = reg.Complete(d.Id, DelegationState.Done, "deployed to /dist")!; + + var digest = DelegationRegistry.Digest(done, Entry("b", "Ninja", busy: false), Array.Empty()); + + Assert.Contains("FINISHED", digest.Body); + Assert.Contains("deployed to /dist", digest.Body); + } + + [Fact] + public void AFailedJobSaysSoRatherThanReadingAsStillWorking() + { + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Sonic", "b", "Ninja", "build it"); + var failed = reg.Complete(d.Id, DelegationState.Failed, "busy")!; + + var digest = DelegationRegistry.Digest(failed, Entry("b", "Ninja"), Array.Empty()); + + Assert.Contains("DID NOT FINISH", digest.Body); + } + + [Fact] + public void AJobWhoseAgentClosedIsReportedStoppedNotRunning() + { + // A tab can close mid-job. Reporting it as "still working" would leave the delegating agent + // waiting on something that can never finish. + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Sonic", "b", "Ninja", "build it"); + + var digest = DelegationRegistry.Digest(d, peer: null, Array.Empty()); + + Assert.Contains("STOPPED", digest.Body); + Assert.Contains("tab was closed", digest.Body); + } + + // ---- The completion card ---------------------------------------------------- + + [Fact] + public void TheCardLeadsWithWhatHappenedNotWithTheBriefItWasGiven() + { + // Observed live: the card echoed back a paragraph of instructions the user had just watched + // their agent compose, and never said what the job actually produced. + var reg = new DelegationRegistry(); + var brief = "Mando would like you to restyle the Xbox Series X25 webpage to be Halo-themed. " + + "Please update the page's visual design to evoke the Halo franchise - think the " + + "Halo green/olive palette, Master Chief / Spartan aesthetic, sci-fi military styling."; + var d = reg.Open("a", "Falchion", "b", "Ninja", brief); + var done = reg.Complete(d.Id, DelegationState.Done, + "Done - the Halo restyle is complete and live in the preview, with a UNSC top bar.")!; + + var card = DelegationRegistry.CompletionLine(done); + + Assert.Contains("Halo restyle is complete", card); // the result is there + Assert.DoesNotContain("Master Chief", card); // the brief is not replayed + Assert.True(card.Length < 300, $"card is {card.Length} chars - too long to scan"); + } + + [Fact] + public void ALongBriefSurvivesOnlyAsAShortLabel() + { + // Enough to tell two outstanding jobs apart; not enough to be a wall of text. + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Falchion", "b", "Ninja", new string('x', 40) + " " + new string('y', 200)); + var done = reg.Complete(d.Id, DelegationState.Done, "finished it")!; + + var card = DelegationRegistry.CompletionLine(done); + + Assert.Contains("…", card); + Assert.DoesNotContain(new string('y', 200), card); + } + + [Fact] + public void APreambleLikeDoneIsSkippedInFavourOfTheRealSentence() + { + // Models routinely open with a bare acknowledgement. A card showing only that says nothing. + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Falchion", "b", "Ninja", "build it"); + var done = reg.Complete(d.Id, DelegationState.Done, + "Done.\n\nThe page now uses an olive palette with a UNSC dossier bar across the top.")!; + + var card = DelegationRegistry.CompletionLine(done); + + Assert.Contains("olive palette", card); + } + + [Fact] + public void AFailedCardCarriesTheReasonRatherThanTheBrief() + { + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Falchion", "b", "Ninja", "build the site"); + var failed = reg.Complete(d.Id, DelegationState.Failed, "busy")!; + + var card = DelegationRegistry.CompletionLine(failed); + + Assert.Contains("did not finish", card); + Assert.Contains("busy", card); + } + + [Fact] + public void AnEmptyResultStillProducesAReadableCard() + { + var reg = new DelegationRegistry(); + var d = reg.Open("a", "Falchion", "b", "Ninja", "build the site"); + var done = reg.Complete(d.Id, DelegationState.Done, "")!; + + var card = DelegationRegistry.CompletionLine(done); + + Assert.Contains("Ninja finished", card); + Assert.DoesNotContain("—", card); // no trailing dash with nothing after it + } + + // ---- The registry ----------------------------------------------------------- + + [Fact] + public void AnAgentSeesOnlyTheJobsItHandedOut() + { + var reg = new DelegationRegistry(); + reg.Open("a", "Sonic", "b", "Ninja", "one"); + reg.Open("c", "Falchion", "b", "Ninja", "two"); + + var mine = reg.OpenedBy("a"); + + Assert.Single(mine); + Assert.Equal("one", mine[0].Task); + } + + [Fact] + public void FinishedJobsAreSweptButRunningOnesSurvive() + { + var reg = new DelegationRegistry(); + var running = reg.Open("a", "Sonic", "b", "Ninja", "still going"); + var done = reg.Open("a", "Sonic", "b", "Ninja", "over"); + reg.Complete(done.Id, DelegationState.Done, "fine"); + + reg.Sweep(TimeSpan.Zero); // everything finished is old enough + + Assert.NotNull(reg.Get(running.Id)); + Assert.Null(reg.Get(done.Id)); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index e097e44..b8bbccc 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -61,6 +61,13 @@ + + + + + + +