From 5d0f5e3cb3063ae4210430c55ffaf2a9bbb6eb1b Mon Sep 17 00:00:00 2001 From: DevMando Date: Tue, 3 Nov 2026 22:39:44 -0800 Subject: [PATCH] feat: add Desktop workspaces and expand agent collaboration --- CHANGELOG.md | 11 +- MandoCode | 2 +- README.md | 2 + docs/agent-mentions.md | 70 +++-- .../AgentInteractionTests.cs | 191 ++++++++++++ .../MandoCode.Desktop.Tests.csproj | 6 + .../PromptFileLookupTests.cs | 96 ++++++ .../PromptMentionPickerTests.cs | 79 +++++ .../WorkspaceStateTests.cs | 38 +++ .../Controls/ChatTabView.Header.cs | 2 +- .../Controls/ChatTabView.Input.cs | 280 ++++++++++++------ .../Controls/ChatTabView.xaml | 16 +- .../Controls/ChatTabView.xaml.cs | 24 +- src/MandoCode.Desktop/MainWindow.History.cs | 9 +- .../MainWindow.Navigation.cs | 2 +- src/MandoCode.Desktop/MainWindow.Shared.cs | 14 +- src/MandoCode.Desktop/MainWindow.Split.cs | 17 +- src/MandoCode.Desktop/MainWindow.Tabs.cs | 44 ++- src/MandoCode.Desktop/MainWindow.Terminal.cs | 38 +-- .../MainWindow.ViewModels.cs | 11 - .../MainWindow.Workspaces.cs | 234 +++++++++++++++ src/MandoCode.Desktop/MainWindow.xaml | 28 +- src/MandoCode.Desktop/MainWindow.xaml.cs | 22 +- .../Services/AgentDirectory.cs | 13 +- .../AgentDirectoryTools.Interactions.cs | 81 +++++ .../Services/AgentDirectoryTools.cs | 24 +- .../Services/AgentInteractionRunner.cs | 26 ++ .../Services/AgentSession.cs | 26 +- .../Services/CommandSuggestion.cs | 9 + src/MandoCode.Desktop/Services/Delegations.cs | 71 ++++- .../Services/PromptFileLookup.cs | 36 +++ .../Services/PromptMentionPicker.cs | 64 ++++ .../Services/SessionAgentPeer.cs | 8 +- .../Services/SessionArchiveStore.cs | 11 +- .../Services/SessionManager.cs | 4 +- .../Services/TranscriptHtmlBuilder.cs | 4 + .../Services/WorkspaceState.cs | 44 ++- .../ViewModels/ChatController.cs | 35 ++- 38 files changed, 1410 insertions(+), 282 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/AgentInteractionTests.cs create mode 100644 src/MandoCode.Desktop.Tests/PromptFileLookupTests.cs create mode 100644 src/MandoCode.Desktop.Tests/PromptMentionPickerTests.cs create mode 100644 src/MandoCode.Desktop.Tests/WorkspaceStateTests.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Workspaces.cs create mode 100644 src/MandoCode.Desktop/Services/AgentDirectoryTools.Interactions.cs create mode 100644 src/MandoCode.Desktop/Services/AgentInteractionRunner.cs create mode 100644 src/MandoCode.Desktop/Services/CommandSuggestion.cs create mode 100644 src/MandoCode.Desktop/Services/PromptFileLookup.cs create mode 100644 src/MandoCode.Desktop/Services/PromptMentionPicker.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dff517..88533d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,20 @@ submodule. ## [Unreleased] +### Added +- **Organize agents into workspaces.** A new upper tab row groups agents into named workspaces. Each workspace remembers its selected agent and split layout; switching workspaces keeps background agents running. Existing tabs move into **Main**, and all workspaces reopen on the next launch. Working and approval badges help you find agents that need attention, including in other workspaces. +- **Close workspaces directly from their tabs.** The **×** appears on the selected workspace and when another workspace is hovered or focused, without shifting tab labels. Right-click still offers Rename and Close. Both closing actions keep at least one workspace open, block closure while agents or commands are running or approvals are waiting, and save conversations to History before closing. +- **More ways for agents to collaborate.** Agents can ask for an immediate answer or a background reply, send information, request a read-only review, hand off work, and update or cancel individual jobs. Messages and results appear in expandable transcript entries that identify the agents involved. Background jobs use readable IDs such as `job1`, support bounded waits, and cancel when an associated agent closes. The existing `ask_agent` tool remains available alongside `ask_agent_and_wait`. + ### Changed -- **Update the bundled engine to the MandoCode 0.16 generation.** Pin the shared engine to `9e25af4`, including the RazorConsole 0.6 migration, maintenance improvements, and merged Windows/Linux CI fixes. The engine now separates commands, requests, lifecycle, model inspection, and history handling into focused code. Desktop keeps its existing WinUI conversation interface; terminal features are described in `MandoCode/docs/CHANGELOG.md`. +- **Two levels of tabs with matching scroll spacing.** Workspace tabs sit above agent tabs. Split and New agent stay beside the agents they control in the selected workspace. Both rows reserve space beneath their tabs for horizontal scrollbars, and agent mentions show the workspace name alongside the project folder. +- **Separate Files and Agents mentions.** Typing `@` opens Files first; press Tab to switch to Agents and back. Gold headers explain the switch. Agent rows show name and project folder, files retain explicit paths when names collide, and quoted paths/names support spaces. +- **Responsive file tagging on large projects.** Reuse the CLI's asynchronous file index, early root listing, shared scan/cache, query debounce, and filesystem watcher. Closing the picker keeps indexing in progress. Build result rows off the UI thread, replace the list in one batch, preserve selection across updates, ignore stale lookups, and show loading/indexing feedback. End drill-down when a selected folder is empty, and dispose file watchers when the tab closes. +- **Update the bundled engine to the MandoCode 0.16 generation.** Pin the shared engine to v0.16.1 (`277cd36`), including the RazorConsole 0.6 migration, maintenance improvements, and merged Windows/Linux CI fixes. The engine now separates commands, requests, lifecycle, model inspection, and history handling into focused code. Desktop keeps its existing WinUI conversation interface; terminal features are described in `MandoCode/docs/CHANGELOG.md`. - **Plan mode starts only through /plan.** Ordinary requests run directly, including multi-step tasks and prose outlines. Desktop no longer automatically proposes structured plans based on task complexity or model tool calls. Existing plan review, editing, execution, and recovery remain available. Settings now explain the explicit command instead of offering automatic planning. ### Fixed +- **Stop peer turns from their receiving tab.** Link incoming peer turns to the target agent's normal cancellation action while keeping each background job's cancellation separate. Inbox messages are consumed on peer turns as well as user turns. - **Resolve Desktop snapshot types explicitly.** Avoid ambiguous references when both the updated engine and Desktop expose snapshot helpers. - **Clear guidance when Ollama retires a model.** HTTP 410 Gone errors, including wrapped provider errors, identify the unavailable model and recommend choosing another with `/model` instead of restarting Ollama or pulling a retired cloud model. The shared engine does not retry these failures. diff --git a/MandoCode b/MandoCode index 9e25af4..277cd36 160000 --- a/MandoCode +++ b/MandoCode @@ -1 +1 @@ -Subproject commit 9e25af4f178e9779de1d426f16dcfa0fa5174c9f +Subproject commit 277cd36e3a10f259ac78e1c0c0a7f09e65523d06 diff --git a/README.md b/README.md index 6bc26ad..a45114a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Two problems, one app: ## Features +- **Workspace tabs** — group agents into named workspaces in the upper tab row. Each workspace remembers its selected agent and split layout; switching keeps background agents running. Use **+ Workspace** to create one. Close with the **×** on the selected tab, or hover/focus another workspace to reveal its close button. Right-click also offers Rename and Close. **Split** and **New agent** apply to the selected workspace. Workspaces restore on launch, and existing installations migrate their tabs into **Main**. Alt+Shift+N creates a workspace, Alt+Shift+Left/Right switches, and Alt+Shift+W closes an idle workspace. + - **Agent tabs, and Split view for up to four at once** — each tab is its own independent conversation, project folder, and model. Split view puts two, three, or four side by side so you can watch several agents work in parallel instead of babysitting one at a time. diff --git a/docs/agent-mentions.md b/docs/agent-mentions.md index 0f5f4be..3f41153 100644 --- a/docs/agent-mentions.md +++ b/docs/agent-mentions.md @@ -1,5 +1,38 @@ # Agent mentions — design +## Desktop collaboration tools + +The current Desktop host exposes the following interactions. The design history below describes the earlier observation/question/delegation stages. + +| Tool | Blocks the requesting turn? | Behavior | +| --- | --- | --- | +| `ask_agent_and_wait` | Yes | Return an idle agent's answer in the current turn. `ask_agent` remains a compatibility alias. | +| `ask_agent_async` | No | Submit a background question; let the user continue chatting. | +| `send_agent_message` | No | Queue information for the recipient's next turn without requesting a reply or starting a model turn. Works while busy. | +| `delegate_to_agent` | No | Background work using the target's normal tools and approval gates. | +| `request_agent_review` | No | Background review; the shared engine's tool middleware enforces read-only access for that turn. Writes, commands, plans, delegation and mutating browser/MCP tools are refused. | +| `handoff_to_agent` | No | Transfer remaining work and context. The sending agent is instructed to stop duplicating it; tabs are not switched. | +| `update_agent_job` | No | Requester or assigned agent posts progress or instructions. Instructions enter the recipient's next turn, rather than interrupting current work. | +| `cancel_agent_job` | No | Requester-only cancellation scoped to that job; already-applied changes remain. | +| `wait_for_agent_job` | Yes, bounded | Explicit wait, 30 seconds by default and at most 120. Timeout/cancel affects only the wait. | +| `check_delegations` | No | Read job IDs, kind, state, latest update, live progress and results. | + +`list_agents`, `get_agent_status`, and `read_agent_transcript` remain available. All requests resolve the target by name, reject self-calls and preserve the existing call-chain loop/depth limits. Busy targets refuse new work rather than silently queuing jobs. The target claims its actual turn atomically; if it became busy after submission, the job reports failure instead of claiming completion. + +Background requests use readable IDs such as `job1`. Cancellation first reports `CancellationRequested`; it becomes `Cancelled` only when the running peer call exits. Terminal results cannot be overwritten by a late completion. Closing an agent requests cancellation of associated jobs. The target's Stop action also cancels peer turns, while a requester stopping its own unrelated turn leaves detached jobs running. + +Completions appear as collapsed transcript entries with the full Markdown result available on expansion. Direct messages and job updates have attributed, collapsed entries in both agents' transcripts. Inbox content is consumed on the next user or peer turn, exactly once. Completion notifications do not automatically start another model turn; the requesting model receives the result on its next turn. No Desktop navigation/layout changes are required for these tools. + +### Manual checks + +- Ask `@OtherAgent` a question in the background, then keep chatting. Expand the completion entry to inspect the full response; ask a follow-up to confirm inbox delivery. +- Compare an explicit blocking question with an async question. Try a busy target and confirm a clear refusal/failure. +- Request a read-only review that attempts a file write or command. Confirm refusal; ordinary delegated work retains normal approval dialogs. +- Send information to a busy agent. It should get a transcript notice immediately and context on its next turn without a competing model turn. +- Start separate jobs and cancel one. Confirm cancellation acknowledgement and that unrelated work continues. A timed-out wait must leave the job running. +- Close an agent involved in a job and confirm the job stops. Verify Stop also works in the receiving tab during a peer turn. + + Status: **built**, except `review_agent_work`. See the build order below. ## What it is @@ -122,36 +155,13 @@ Open problems, all of which belong to this tool alone: ## 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. +Typing `@` opens **Files — Press [Tab] for Agents**. Press Tab to show **Agents — Press [Tab] for Files**, and press it again to switch back. Files and agents stay in separate lists. Typing continues in the input, arrows navigate, Enter accepts a file or agent, and Escape dismisses. Tab still accepts command and emoji suggestions. Shift+Enter retains the input's multiline behavior. The current agent is excluded; agent rows show the callsign and project folder without a busy/idle suffix. + +File suggestions use the engine's background index rather than synchronous recursive enumeration. Root entries publish before descendant scanning finishes. One scan continues across typing, switching views, and closing/reopening the popup. Filtering is debounced by the shared provider, and UI result projection runs off the dispatcher. A virtualized, height-bounded ListView receives a single result array, without a match limit or thousands of individual collection notifications. Refreshes retain the selected path; stale/cancelled UI requests cannot reopen a dismissed picker or replace a newer query. Loading and indexing feedback stay outside the selectable rows. + +After indexing, the provider applies queued create/delete/rename events incrementally when lookup resumes. Watcher overflow invalidates the cache for recovery. Project changes start the appropriate index and old results are discarded. Closing a tab unsubscribes the UI and disposes its provider/watcher. This is an in-memory cache for the agent's lifetime, not a persistent cross-process index; initial full indexing still takes work on huge trees. + +Selecting a folder drills into its immediate children; an empty folder ends drill-down and leaves the selected directory in the input. Paths with spaces use quoted reference tokens. A file whose name resolves to an agent is inserted with `./` so it stays a file reference. The host parses the same token format when assembling file context and agent mentions. ## Who am I talking to diff --git a/src/MandoCode.Desktop.Tests/AgentInteractionTests.cs b/src/MandoCode.Desktop.Tests/AgentInteractionTests.cs new file mode 100644 index 0000000..6415133 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/AgentInteractionTests.cs @@ -0,0 +1,191 @@ +using MandoCode.Desktop.Services; +using MandoCode.Services; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +public sealed class AgentInteractionTests +{ + private sealed class Peer(Func> run) : IAgentPeer + { + public string Key => "target"; + public bool IsBusy { get; init; } + public int Calls { get; private set; } + public string? Brief { get; private set; } + public Task AskAsync(string askedBy, string question, CancellationToken cancellationToken = default) + { Calls++; Brief = question; return run(cancellationToken); } + } + private static AgentEntry Entry(string key, string name) => new(key, name, "C:\\project", "project", "model", false, false, 0, 0); + private static (AgentDirectory Directory, AgentDirectoryTools Tools, Peer Peer) Setup(Action? start = null, bool busy = false) + { + var directory = new AgentDirectory(); + directory.Replace([Entry("owner", "Owner"), Entry("target", "Target"), Entry("other", "Other")]); + var peer = new Peer(_ => Task.FromResult(PeerAnswer.Ok("Answer"))) { IsBusy = busy }; + directory.RegisterPeer(peer); + return (directory, new(directory, "owner", () => "Owner", start), peer); + } + + [Theory] + [InlineData("question", false)] + [InlineData("review", true)] + [InlineData("handoff", false)] + public void BackgroundInteractionsReturnImmediatelyWithReadableIds(string kind, bool readOnly) + { + Delegation? started = null; + var (_, tools, peer) = Setup((job, _) => started = job); + var reply = kind switch { "review" => tools.RequestAgentReview("Target", "Check this"), "handoff" => tools.HandoffToAgent("Target", "Take over"), _ => tools.AskAgentAsync("Target", "Question") }; + Assert.NotNull(started); Assert.Equal("job1", started.Id); Assert.Contains("job1", reply); + Assert.Equal(kind, started.Kind); Assert.Equal(readOnly, started.ReadOnly); Assert.Equal(0, peer.Calls); + } + + [Fact] + public void BusyTargetsRefuseWorkButCanReceiveMessagesWithoutStartingATurn() + { + var (directory, tools, peer) = Setup((_, _) => throw new Exception("Must not start"), busy: true); + Assert.Contains("busy", tools.AskAgentAsync("Target", "Question")); + Assert.Empty(directory.Delegations.OpenedBy("owner")); + Assert.Contains("No reply requested", tools.SendAgentMessage("Target", "Information")); + Assert.Equal("Information", Assert.Single(directory.InboxFor("target").Drain()).Body); + Assert.Empty(directory.InboxFor("target").Drain()); Assert.Equal(0, peer.Calls); + } + + [Fact] + public void MessagesHaveHostAttributedTranscriptEvidenceForBothAgents() + { + var (directory, tools, _) = Setup(); + AgentCommunication? notice = null; + directory.CommunicationPosted += n => notice = n; + tools.SendAgentMessage("Target", "Useful context"); + Assert.NotNull(notice); + Assert.Equal("owner", notice.FromKey); Assert.Equal("target", notice.ToKey); + Assert.Equal("Owner", notice.FromName); Assert.Equal("Target", notice.ToName); + Assert.Contains("no reply requested", notice.Title); Assert.Equal("Useful context", notice.Body); + } + + [Fact] + public void MissingSelfEmptyAndLoopedRequestsDoNotCreateJobs() + { + var (directory, tools, _) = Setup(); + tools.AskAgentAsync("Missing", "Question"); tools.RequestAgentReview("Owner", "Task"); tools.HandoffToAgent("Target", " "); + using (AgentCallChain.Enter("target")) Assert.Contains("loop", tools.AskAgentAsync("Target", "Question")); + Assert.Empty(directory.Delegations.OpenedBy("owner")); + } + + [Fact] + public async Task BlockingAliasReturnsTheAnswerAndForwardsCancellation() + { + var (directory, tools, _) = Setup(); + using var cts = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + directory.RegisterPeer(new Peer(async ct => { entered.SetResult(); await Task.Delay(Timeout.Infinite, ct); return PeerAnswer.Ok("unused"); })); + var pending = tools.AskAgentAndWait("Target", "Question", cts.Token); + await entered.Task; cts.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + Assert.Empty(directory.Delegations.OpenedBy("owner")); + } + + [Theory] + [InlineData("write_file")] + [InlineData("execute_command")] + [InlineData("delegate_to_agent")] + [InlineData("open_browser_tab")] + public async Task ReadOnlyReviewActuallyBlocksMutatingToolsThroughEngineMiddleware(string tool) + { + var registry = new DelegationRegistry(); + var job = registry.Open("owner", "Owner", "target", "Target", "Review", "review", true); + var calls = 0; + var function = AIFunctionFactory.Create(() => { calls++; return "Executed"; }, new() { Name = tool }); + var peer = new Peer(async _ => + { + await Task.Yield(); Assert.True(CliAgentCallChain.IsReview); + var context = new FunctionInvocationContext { Function = function, Arguments = new() }; + var result = await new AgentFunctionMiddleware(0).InterceptAsync(null!, context, + (ctx, ct) => ctx.Function.InvokeAsync(ctx.Arguments, ct), default); + Assert.Contains("read-only", result?.ToString()); + return PeerAnswer.Ok("Findings"); + }); + Assert.Equal(DelegationState.Done, (await AgentInteractionRunner.Run(registry, job, peer)).State); + Assert.Equal(0, calls); Assert.False(CliAgentCallChain.IsReview); + } + + [Fact] + public async Task ReviewsAllowInspectionAndNormalDelegationCanUseItsTools() + { + foreach (var review in new[] { true, false }) + { + var registry = new DelegationRegistry(); + var job = registry.Open("owner", "Owner", "target", "Target", "Task", readOnly: review); + var calls = 0; + var function = AIFunctionFactory.Create(() => { calls++; return "Content"; }, new() { Name = review ? "read_file_contents" : "custom_host_tool" }); + var peer = new Peer(async _ => + { + var context = new FunctionInvocationContext { Function = function, Arguments = new() }; + await new AgentFunctionMiddleware(0).InterceptAsync(null!, context, (ctx, ct) => ctx.Function.InvokeAsync(ctx.Arguments, ct), default); + return PeerAnswer.Ok("Done"); + }); + await AgentInteractionRunner.Run(registry, job, peer); Assert.Equal(1, calls); + } + } + + [Fact] + public async Task CancellationIsOwnerOnlyAndWaitsForAcknowledgementWithoutCancellingOtherJobs() + { + var registry = new DelegationRegistry(); + var job = registry.Open("owner", "Owner", "target", "Target", "Task"); + var second = registry.Open("owner", "Owner", "other", "Other", "Other task"); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var exit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var run = AgentInteractionRunner.Run(registry, job, new Peer(_ => { entered.SetResult(); return exit.Task; })); + await entered.Task; + Assert.False(registry.Cancel(job.Id, "stranger")); Assert.False(registry.Token(job.Id).IsCancellationRequested); + Assert.True(registry.Cancel(job.Id, "owner")); Assert.True(registry.Token(job.Id).IsCancellationRequested); + Assert.Equal(DelegationState.CancellationRequested, registry.Get(job.Id)!.State); + Assert.False(registry.Token(second.Id).IsCancellationRequested); + exit.SetResult(PeerAnswer.Ok("Late success")); + Assert.Equal(DelegationState.Cancelled, (await run).State); + Assert.Equal(DelegationState.Cancelled, registry.Complete(job.Id, DelegationState.Done, "Later success")!.State); + } + + [Fact] + public async Task WaitTimeoutAndCancellationLeaveTheJobRunning() + { + var (directory, tools, _) = Setup(); + var job = directory.Delegations.Open("owner", "Owner", "target", "Target", "Task"); + Assert.Contains("timed out", await tools.WaitForAgentJob(job.Id, 0)); + using var cts = new CancellationTokenSource(); cts.Cancel(); + await Assert.ThrowsAnyAsync(() => tools.WaitForAgentJob(job.Id, 30, cts.Token)); + Assert.Equal(DelegationState.Running, directory.Delegations.Get(job.Id)!.State); + Assert.False(directory.Delegations.Token(job.Id).IsCancellationRequested); + directory.Delegations.Complete(job.Id, DelegationState.Done, "Result"); + Assert.Contains("Result", await tools.WaitForAgentJob(job.Id)); + } + + [Fact] + public void UpdatesRequireJobMembershipAndAreQueuedOnceWithLatestValue() + { + var (directory, tools, _) = Setup(); + var job = directory.Delegations.Open("owner", "Owner", "target", "Target", "Task"); + Assert.False(directory.Delegations.UpdateJob(job.Id, "other", "Wrong agent")); + tools.UpdateAgentJob(job.Id, "First"); tools.UpdateAgentJob(job.Id, "Latest"); + Assert.Equal("Latest", Assert.Single(directory.InboxFor("target").Drain()).Body); + Assert.Contains("Latest", tools.CheckDelegations()); Assert.Contains("job1", tools.CheckDelegations()); + directory.Delegations.Complete(job.Id, DelegationState.Done, "Done"); + Assert.False(directory.Delegations.UpdateJob(job.Id, "owner", "Too late")); + } + + [Fact] + public async Task ClosingAnAgentCancelsItsJobsAndBusyRaceReportsFailure() + { + var (directory, _, peer) = Setup(); + var job = directory.Delegations.Open("owner", "Owner", "target", "Target", "Task"); + directory.RemovePeer("target"); + Assert.True(directory.Delegations.Token(job.Id).IsCancellationRequested); + Assert.Equal(DelegationState.Cancelled, (await AgentInteractionRunner.Run(directory.Delegations, job, peer)).State); + Assert.Equal(0, peer.Calls); + var declined = directory.Delegations.Open("owner", "Owner", "target", "Target", "Task"); + var result = await AgentInteractionRunner.Run(directory.Delegations, declined, new Peer(_ => Task.FromResult(PeerAnswer.Busy()))); + Assert.Equal(DelegationState.Failed, result.State); Assert.Equal("busy", result.Result); + } +} diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 5dff56c..5ad680e 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -31,6 +31,9 @@ Windows App SDK into the test host). If these files ever grow UI dependencies, that's a design regression this project exists to catch. --> + + + @@ -38,6 +41,7 @@ + - + + + + + + + + + + + @@ -821,9 +843,7 @@ other fix and the wrong one: #007ACC IS the Visual Studio blue, and correcting it would break the exact thing that theme exists to reproduce. The bottom margin comes down with the size to hold the baseline against the tab strip. --> - + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index 1be98f8..546ac89 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -95,6 +95,7 @@ public MainWindow() _skillCoordinator = services.GetRequiredService(); _configs = services.GetRequiredService(); _agentDirectory = services.GetRequiredService(); + _agentDirectory.CommunicationPosted += OnAgentCommunication; // The rail page edits the global defaults. Bound once, for the life of the window: unlike an // agent's pane there is nothing to re-point it at. @@ -141,26 +142,12 @@ public MainWindow() // Reopen the previous workspace shape (tabs, roots, models, active tab). Folders // that no longer exist are skipped; no saved shape (or nothing usable) means the // classic single default tab. - var shape = WorkspaceState.TryLoad(); - if (shape != null) - { - foreach (var t in shape.Tabs.Where(t => Directory.Exists(t.ProjectRoot))) - CreateChatTab(t.ProjectRoot, t.Title, t.Model, t.Key); - if (_tabs.Count == 0) CreateChatTab(); - else SelectTab(_tabs[Math.Clamp(shape.ActiveIndex, 0, _tabs.Count - 1)]); - // After the tabs exist and one is selected — the pane set is matched by persist-key - // and only shows if the restored active agent is one of its panes. - RestoreSplitLayout(shape); - } - else - { - CreateChatTab(); - } + RestoreWorkspaces(); // Journals whose sessions no longer exist (tabs closed during a crash, pruned // folders) have nothing to replay into — clean them up. Archived (closed-but-recoverable) // sessions are kept: their files back the History panel, so their keys join the keep-set. - var liveKeys = _tabs.Select(t => t.View.Session.PersistKey).Concat(_archive.Keys).ToList(); + var liveKeys = AllTabs.Select(t => t.View.Session.PersistKey).Concat(_archive.Keys).ToList(); TranscriptJournal.Sweep(liveKeys); ConversationLog.Sweep(liveKeys); SessionHistoryStore.Sweep(liveKeys); @@ -188,11 +175,12 @@ public MainWindow() /// private void MainWindow_Closed(object sender, WindowEventArgs args) { + _agentDirectory.CommunicationPosted -= OnAgentCommunication; SaveWorkspace(); // capture the shape BEFORE teardown starts mutating state // A note being typed when the window closes must land on disk — autosave runs on a debounce, // so the last few seconds of typing are still only in the TextBox at this point. NoteEditor.Shutdown(); - foreach (var tab in _tabs) tab.View.Shutdown(); + foreach (var tab in AllTabs) tab.View.Shutdown(); _terminal?.ShutDown(); // kill any ConPTY shells so no processes leak _musicPollTimer?.Stop(); // or its ticks keep touching a disposed service and closed XAML diff --git a/src/MandoCode.Desktop/Services/AgentDirectory.cs b/src/MandoCode.Desktop/Services/AgentDirectory.cs index 697fecf..a44bd3f 100644 --- a/src/MandoCode.Desktop/Services/AgentDirectory.cs +++ b/src/MandoCode.Desktop/Services/AgentDirectory.cs @@ -12,7 +12,10 @@ public sealed record AgentEntry( bool IsBusy, bool IsRunningCommand, int PlanStep, - int PlanTotal); + int PlanTotal, + string Workspace = "Main"); + +public sealed record AgentCommunication(string FromKey, string ToKey, string FromName, string ToName, string Title, string Body); /// /// The host's register of open agents, so one agent can be addressed by another. Populated by @@ -54,6 +57,11 @@ public AgentInbox InboxFor(string key) /// Raised after the register changes, so the UI can re-offer suggestions. public event Action? Changed; + /// Host-authored transcript evidence for queued messages and updates. No model turn. + public event Action? CommunicationPosted; + + public void AnnounceCommunication(AgentCommunication communication) => CommunicationPosted?.Invoke(communication); + public IReadOnlyList All { get { lock (_lock) { return _agents.ToList(); } } @@ -80,6 +88,7 @@ public void RegisterPeer(IAgentPeer peer) public void RemovePeer(string key) { lock (_lock) { _peers.Remove(key); } + Delegations.CloseAgent(key); } /// The live agent for a key, or null if its tab has since closed. @@ -146,7 +155,7 @@ public IReadOnlyList RecentCommandsFor(string key) => /// public static string Describe(AgentEntry a) { - var parts = new List { $"Agent \"{a.Name}\"", $"working in {a.FolderLabel} ({a.ProjectRoot})", $"model {a.Model}" }; + var parts = new List { $"Agent \"{a.Name}\"", $"workspace {a.Workspace}", $"working in {a.FolderLabel} ({a.ProjectRoot})", $"model {a.Model}" }; if (a.PlanTotal > 0) parts.Add($"executing a plan, step {a.PlanStep} of {a.PlanTotal}"); parts.Add(a.IsBusy ? "currently WORKING on a turn" : "IDLE — not currently working"); if (a.IsRunningCommand) parts.Add("a shell command is running right now"); diff --git a/src/MandoCode.Desktop/Services/AgentDirectoryTools.Interactions.cs b/src/MandoCode.Desktop/Services/AgentDirectoryTools.Interactions.cs new file mode 100644 index 0000000..e1a5b1b --- /dev/null +++ b/src/MandoCode.Desktop/Services/AgentDirectoryTools.Interactions.cs @@ -0,0 +1,81 @@ +using System.ComponentModel; + +namespace MandoCode.Desktop.Services; + +public sealed partial class AgentDirectoryTools +{ + [Description("BLOCK this turn until another agent answers a question needed immediately. Prefer ask_agent_async when the answer can arrive later and the user should stay free to chat.")] + public Task AskAgentAndWait(string name, string question, CancellationToken cancellationToken = default) => AskAgent(name, question, cancellationToken); + + [Description("Ask a question in the BACKGROUND, returning a job ID immediately. Finish this turn and let the user keep chatting; the result arrives in a completion notice and next-turn inbox context. Do not wait unless the answer is necessary to continue.")] + public string AskAgentAsync(string name, string question) => StartInteraction(name, question, "question"); + + [Description("Send information without requesting a reply or starting a model turn. Queues for the recipient's next turn, even while busy. Does not interrupt current work.")] + public string SendAgentMessage(string name, string message) + { + var agent = _directory.Resolve(name); + if (agent == null) return NotFound(name); + if (agent.Key == _selfKey) return "That agent is you."; + if (_directory.Peer(agent.Key) == null) return "That agent has closed."; + if (string.IsNullOrWhiteSpace(message)) return "Write a message first."; + _directory.InboxFor(agent.Key).Post(new InboxMessage(Guid.NewGuid().ToString("N"), $"Message from {_selfName}", message, DateTimeOffset.Now)); + _directory.AnnounceCommunication(new(_selfKey, agent.Key, _selfName, agent.Name, "Message queued · no reply requested", message)); + return $"Message queued for {agent.Name}'s next turn. No reply requested."; + } + + [Description("Request a BACKGROUND read-only review. The host blocks writes, commands, plans, delegation and other mutating tools during the review. Include the scope and acceptance criteria. Returns a job ID; the result arrives later.")] + public string RequestAgentReview(string name, string task) => StartInteraction(name, task, "review", true); + + [Description("Transfer remaining work to another agent in the BACKGROUND. Include the goal, decisions, current state, files and acceptance criteria. After submitting, stop duplicating the task and tell the user which agent is taking over. This does not switch tabs.")] + public string HandoffToAgent(string name, string task) => StartInteraction(name, task, "handoff"); + + [Description("Post a progress update or additional instructions for a running job. Only the requester and assigned agent can update it. Instructions are queued for the recipient's next turn and do not rewrite its active request; cancel/reassign if current work must stop.")] + public string UpdateAgentJob(string jobId, string message) + { + if (string.IsNullOrWhiteSpace(message)) return "Write an update first."; + if (!_directory.Delegations.UpdateJob(jobId, _selfKey, message)) return "Job not found, no longer running, or belongs to other agents."; + var job = _directory.Delegations.Get(jobId)!; + var recipient = job.FromKey == _selfKey ? job.ToKey : job.FromKey; + _directory.InboxFor(recipient).Post(new InboxMessage($"update:{jobId}", $"{jobId}: update from {_selfName}", message, DateTimeOffset.Now)); + _directory.AnnounceCommunication(new(_selfKey, recipient, _selfName, + job.FromKey == _selfKey ? job.ToName : job.FromName, $"{jobId} · Update queued for next turn", message)); + return $"Updated {jobId}. Queued for the recipient's next turn."; + } + + [Description("Request cancellation of a background job YOU assigned. Cancels only that job's turn, not unrelated work. check_delegations reports acknowledgement; completed file changes are preserved.")] + public string CancelAgentJob(string jobId) => _directory.Delegations.Cancel(jobId, _selfKey) + ? $"Cancellation requested for {jobId}. Changes already applied remain." + : "Job not found, no longer running, or not assigned by you."; + + [Description("Explicitly BLOCK waiting for a background job you assigned, for up to 120 seconds. Timing out or cancelling this wait does not cancel the job. Prefer check_delegations for non-blocking progress; avoid polling loops.")] + public async Task WaitForAgentJob(string jobId, int timeoutSeconds = 30, CancellationToken cancellationToken = default) + { + var timer = System.Diagnostics.Stopwatch.StartNew(); + var timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 0, 120)); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var job = _directory.Delegations.Get(jobId); + if (job == null || job.FromKey != _selfKey) return "Job not found or not assigned by you."; + if (job.State is DelegationState.Done or DelegationState.Failed or DelegationState.Cancelled) + return $"{job.Id}: {job.State}\n{job.Result}"; + if (timer.Elapsed >= timeout) return $"{job.Id}: {job.State}. Only the wait timed out; the job was not cancelled."; + await Task.Delay(150, cancellationToken); + } + } + + private string StartInteraction(string name, string task, string kind, bool readOnly = false) + { + var agent = _directory.Resolve(name); + if (agent == null) return NotFound(name); + if (agent.Key == _selfKey) return "That agent is you."; + if (string.IsNullOrWhiteSpace(task)) return "Describe the request first."; + var peer = _directory.Peer(agent.Key); + if (peer == null) return "That agent has closed."; + if (peer.IsBusy) return $"{agent.Name} is busy. Try later, read its status/transcript, or send_agent_message to queue information."; + if (AgentCallChain.Reject(agent.Key, agent.Name) is { } rejection) return rejection; + var job = _directory.Delegations.Open(_selfKey, _selfName, agent.Key, agent.Name, task, kind, readOnly); + _start(job, peer); + return $"{job.Id}: {kind} submitted to {agent.Name}. The target will attempt to claim its turn. Finish this turn and let the user continue chatting; completion arrives automatically."; + } +} diff --git a/src/MandoCode.Desktop/Services/AgentDirectoryTools.cs b/src/MandoCode.Desktop/Services/AgentDirectoryTools.cs index a386bbd..6b196d5 100644 --- a/src/MandoCode.Desktop/Services/AgentDirectoryTools.cs +++ b/src/MandoCode.Desktop/Services/AgentDirectoryTools.cs @@ -6,13 +6,9 @@ namespace MandoCode.Desktop.Services; /// Cross-agent tools, exposed to the model through SetHostTools — the same seam the browser /// preview tools use. Host-owned rather than engine-owned because only the host knows what other /// agents exist; an agent's own tools stay bounded to its own project root. -/// -/// Everything here is OBSERVATION. Nothing in this class runs a turn on another agent's model, -/// which is why none of it can loop, none of it collides with another agent being busy, and none of -/// it raises an approval question. Asking another agent a real question is a separate tool with a -/// genuinely different risk profile — see docs/agent-mentions.md. +/// Observation, blocking questions, background work and queued messages use separate tools. /// -public sealed class AgentDirectoryTools +public sealed partial class AgentDirectoryTools { private readonly AgentDirectory _directory; private readonly string _selfKey; @@ -47,7 +43,7 @@ public string ListAgents() return "Other open agents:\n" + string.Join("\n", - others.Select(a => $"- {a.Name} — {a.FolderLabel}{(a.IsBusy ? " (working)" : " (idle)")}")); + others.Select(a => $"- {a.Name} — workspace {a.Workspace} · {a.FolderLabel}{(a.IsBusy ? " (working)" : " (idle)")}")); } [Description("Reports what another agent is doing right now: whether it is working or idle, how " + @@ -73,7 +69,7 @@ public string GetAgentStatus( return AgentDirectory.Describe(agent); } - [Description("Asks another agent a question and returns its answer in its own words. The other " + + [Description("BLOCKS this turn until another agent answers. Prefer ask_agent_async for questions whose results can arrive later so the user can keep chatting. The other " + "agent answers from what it has been working on and can use its own tools to check, " + "so prefer this over reading its transcript when you want a specific answer. Only " + "works when that agent is idle: if it is busy, this returns a note saying so, and you " + @@ -82,7 +78,7 @@ public string GetAgentStatus( public async Task AskAgent( [Description("The agent's name, as the user typed it after '@'.")] string name, [Description("The question, phrased as you would ask a colleague. Be specific — the other " + - "agent cannot see your conversation.")] string question) + "agent cannot see your conversation.")] string question, CancellationToken cancellationToken = default) { var agent = _directory.Resolve(name); if (agent == null) return NotFound(name); @@ -100,7 +96,7 @@ public async Task AskAgent( // checking first and asking second left a gap where the agent could take a turn in between, // and produced a second, worse-worded refusal from the far side. One decision, one place; // this layer only chooses how to say it, because only this layer knows the directory. - var result = await peer.AskAsync(_selfName, question); + var result = await peer.AskAsync(_selfName, question, cancellationToken); if (result.Answered) return $"{agent.Name} replied:\n{result.Text}"; return $"{agent.Name} could not answer ({result.Text}). " + @@ -132,7 +128,7 @@ public string ReadAgentTranscript( [Description("Hands a JOB to another agent and returns immediately, without waiting for it to " + "finish. Use this instead of ask_agent whenever the work will take a while — building " + - "something, running a long task — so you stay free to keep talking to the user. You " + + "something, running a long task — so you stay free to keep talking to the user. For background questions use ask_agent_async; for a read-only review use request_agent_review. You " + "will be told automatically when it finishes, and check_delegations tells you how far " + "along it is at any point. Use ask_agent instead for a quick question you need " + "answered right now.")] @@ -160,8 +156,8 @@ public string DelegateToAgent( var delegation = _directory.Delegations.Open(_selfKey, _selfName, agent.Key, agent.Name, task); _start(delegation, peer); - return $"Handed to {agent.Name}: {task}\n" + - "It is working on this now. You will be told when it finishes — carry on with the user " + + return $"{delegation.Id}: Submitted to {agent.Name}: {task}\n" + + "The target will attempt to claim its turn. You will be told when it finishes — carry on with the user " + "in the meantime, and use check_delegations if they ask how it is going."; } @@ -177,7 +173,7 @@ public string CheckDelegations() { var peer = _directory.All.FirstOrDefault(a => a.Key == d.ToKey); var digest = DelegationRegistry.Digest(d, peer, _directory.RecentCommandsFor(d.ToKey)); - return $"{digest.Subject}\n{digest.Body}"; + return $"{d.Id} · {d.Kind} · {d.State} · {digest.Subject}\n{digest.Body}"; })); } diff --git a/src/MandoCode.Desktop/Services/AgentInteractionRunner.cs b/src/MandoCode.Desktop/Services/AgentInteractionRunner.cs new file mode 100644 index 0000000..47a8653 --- /dev/null +++ b/src/MandoCode.Desktop/Services/AgentInteractionRunner.cs @@ -0,0 +1,26 @@ +namespace MandoCode.Desktop.Services; + +/// Execution boundary shared by Desktop background questions, jobs, reviews and handoffs. +public static class AgentInteractionRunner +{ + public static async Task Run(DelegationRegistry jobs, Delegation job, IAgentPeer target) + { + DelegationState state; + string result; + try + { + var ct = jobs.Token(job.Id); + ct.ThrowIfCancellationRequested(); + // The shared engine's middleware checks this scope before ANY tool executes. + using var review = MandoCode.Services.CliAgentCallChain.Enter(job.ToKey, review: job.ReadOnly); + var brief = $"[{job.Kind} {job.Id}] {job.Task}"; + if (job.ReadOnly) brief += "\nRead-only review: inspect and report findings, suggested changes and validation gaps. Do not modify files or execute commands."; + var answer = await target.AskAsync(job.FromName, brief, ct); + state = ct.IsCancellationRequested ? DelegationState.Cancelled : answer.Answered ? DelegationState.Done : DelegationState.Failed; + result = answer.Text; + } + catch (OperationCanceledException) { state = DelegationState.Cancelled; result = "Job cancelled. Changes already applied remain."; } + catch (Exception ex) { state = DelegationState.Failed; result = ex.Message; } + return jobs.Complete(job.Id, state, result) ?? job; + } +} diff --git a/src/MandoCode.Desktop/Services/AgentSession.cs b/src/MandoCode.Desktop/Services/AgentSession.cs index 893668b..c48817f 100644 --- a/src/MandoCode.Desktop/Services/AgentSession.cs +++ b/src/MandoCode.Desktop/Services/AgentSession.cs @@ -142,6 +142,14 @@ public AgentSession( Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.AskAgent, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "ask_agent" }), Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.ReadAgentTranscript, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "read_agent_transcript" }), Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.DelegateToAgent, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "delegate_to_agent" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.AskAgentAndWait, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "ask_agent_and_wait" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.AskAgentAsync, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "ask_agent_async" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.SendAgentMessage, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "send_agent_message" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.RequestAgentReview, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "request_agent_review" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.HandoffToAgent, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "handoff_to_agent" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.UpdateAgentJob, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "update_agent_job" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.CancelAgentJob, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "cancel_agent_job" }), + Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.WaitForAgentJob, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "wait_for_agent_job" }), Microsoft.Extensions.AI.AIFunctionFactory.Create(agentTools.CheckDelegations, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "check_delegations" }), Microsoft.Extensions.AI.AIFunctionFactory.Create(PreviewTools.ListBrowserFrames, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "list_browser_frames" }), Microsoft.Extensions.AI.AIFunctionFactory.Create(PreviewTools.ListBrowserTabs, new Microsoft.Extensions.AI.AIFunctionFactoryOptions { Name = "list_browser_tabs" }), @@ -333,21 +341,9 @@ private void StartDelegation(Delegation delegation, IAgentPeer target) _ = Task.Run(async () => { - DelegationState state; - string? result; - try - { - var answer = await target.AskAsync(delegation.FromName, delegation.Task); - state = answer.Answered ? DelegationState.Done : DelegationState.Failed; - result = answer.Text; - } - catch (Exception ex) - { - state = DelegationState.Failed; - result = ex.Message; - } - - var finished = Agents.Delegations.Complete(delegation.Id, state, result) ?? delegation; + var finished = ReferenceEquals(Agents.Peer(delegation.ToKey), target) + ? await AgentInteractionRunner.Run(Agents.Delegations, delegation, target) + : Agents.Delegations.Complete(delegation.Id, DelegationState.Failed, "The target agent closed before starting.") ?? delegation; inbox.Post(DelegationRegistry.Digest(finished, entry(), Agents.RecentCommandsFor(finished.ToKey))); DelegationFinished?.Invoke(finished); }); diff --git a/src/MandoCode.Desktop/Services/CommandSuggestion.cs b/src/MandoCode.Desktop/Services/CommandSuggestion.cs new file mode 100644 index 0000000..f35721e --- /dev/null +++ b/src/MandoCode.Desktop/Services/CommandSuggestion.cs @@ -0,0 +1,9 @@ +namespace MandoCode.Desktop; + +/// Immutable row for command, file, agent, and emoji suggestions. +public sealed class CommandSuggestion +{ + public string Command { get; init; } = ""; + public string Description { get; init; } = ""; + public string? InsertText { get; init; } +} diff --git a/src/MandoCode.Desktop/Services/Delegations.cs b/src/MandoCode.Desktop/Services/Delegations.cs index 31eb81a..548fd2f 100644 --- a/src/MandoCode.Desktop/Services/Delegations.cs +++ b/src/MandoCode.Desktop/Services/Delegations.cs @@ -1,6 +1,6 @@ namespace MandoCode.Desktop.Services; -public enum DelegationState { Running, Done, Failed } +public enum DelegationState { Running, Done, Failed, CancellationRequested, Cancelled } /// One job one agent handed to another. public sealed record Delegation( @@ -13,7 +13,8 @@ public sealed record Delegation( DateTimeOffset StartedAt, DelegationState State = DelegationState.Running, string? Result = null, - DateTimeOffset? FinishedAt = null); + DateTimeOffset? FinishedAt = null, + string Kind = "job", bool ReadOnly = false, string? Update = null); /// /// Outstanding delegations, and the rolling digest each one contributes to its owner's inbox. @@ -29,13 +30,15 @@ public sealed class DelegationRegistry private readonly object _lock = new(); private readonly Dictionary _byId = new(StringComparer.Ordinal); private int _next; + private readonly Dictionary _cancellations = new(StringComparer.Ordinal); - public Delegation Open(string fromKey, string fromName, string toKey, string toName, string task) + public Delegation Open(string fromKey, string fromName, string toKey, string toName, string task, string kind = "job", bool readOnly = false) { lock (_lock) { - var d = new Delegation($"d{++_next}", fromKey, fromName, toKey, toName, task, DateTimeOffset.Now); + var d = new Delegation($"job{++_next}", fromKey, fromName, toKey, toName, task, DateTimeOffset.Now, Kind: kind, ReadOnly: readOnly); _byId[d.Id] = d; + _cancellations[d.Id] = new CancellationTokenSource(); return d; } } @@ -60,12 +63,57 @@ public IReadOnlyList OpenedBy(string fromKey) lock (_lock) { if (!_byId.TryGetValue(id, out var d)) return null; + if (d.State is DelegationState.Done or DelegationState.Failed or DelegationState.Cancelled) return d; + if (d.State == DelegationState.CancellationRequested) { state = DelegationState.Cancelled; result = "Cancelled by requester or agent closure. Changes already applied remain."; } var done = d with { State = state, Result = result, FinishedAt = DateTimeOffset.Now }; _byId[id] = done; return done; } } + public CancellationToken Token(string id) + { + lock (_lock) return _cancellations.TryGetValue(id, out var cts) ? cts.Token : CancellationToken.None; + } + + public bool UpdateJob(string id, string caller, string message) + { + lock (_lock) + { + if (!_byId.TryGetValue(id, out var job) || job.State != DelegationState.Running + || (job.FromKey != caller && job.ToKey != caller)) return false; + _byId[id] = job with { Update = message }; + return true; + } + } + + public bool Cancel(string id, string caller) + { + CancellationTokenSource? cts; + lock (_lock) + { + if (!_byId.TryGetValue(id, out var job) || job.FromKey != caller || job.State != DelegationState.Running) return false; + _byId[id] = job with { State = DelegationState.CancellationRequested }; + _cancellations.TryGetValue(id, out cts); + } + cts?.Cancel(); + return true; + } + + public void CloseAgent(string key) + { + List cancellations = []; + lock (_lock) + { + foreach (var job in _byId.Values.Where(j => j.State == DelegationState.Running && (j.FromKey == key || j.ToKey == key)).ToArray()) + { + _byId[job.Id] = job with { State = DelegationState.CancellationRequested }; + if (_cancellations.TryGetValue(job.Id, out var cts)) cancellations.Add(cts); + } + } + foreach (var cts in cancellations) cts.Cancel(); + } + /// Forgets finished delegations older than , so the registry /// does not accumulate a session's worth of completed work. public void Sweep(TimeSpan keepFor) @@ -75,7 +123,10 @@ public void Sweep(TimeSpan keepFor) { foreach (var id in _byId.Where(kv => kv.Value.FinishedAt is { } f && f < cutoff) .Select(kv => kv.Key).ToList()) + { _byId.Remove(id); + if (_cancellations.Remove(id, out var cts)) cts.Dispose(); + } } } @@ -94,6 +145,8 @@ public static InboxMessage Digest(Delegation d, AgentEntry? peer, IReadOnlyList< var age = Age(DateTimeOffset.Now - d.StartedAt); var lines = new List { $"You asked {d.ToName} to: {d.Task}" }; + if (!string.IsNullOrWhiteSpace(d.Update)) lines.Add($"Latest update: {d.Update}"); + switch (d.State) { case DelegationState.Done: @@ -101,6 +154,14 @@ public static InboxMessage Digest(Delegation d, AgentEntry? peer, IReadOnlyList< if (!string.IsNullOrWhiteSpace(d.Result)) lines.Add($"{d.ToName} reported: {d.Result}"); break; + case DelegationState.Cancelled: + lines.Add("CANCELLED. Changes already applied remain."); + break; + + case DelegationState.CancellationRequested: + lines.Add("Cancellation requested — waiting for the running turn to stop."); + break; + case DelegationState.Failed: lines.Add($"DID NOT FINISH: {d.Result ?? "no reason given"}."); break; @@ -175,6 +236,8 @@ private static string Shorten(string text, int max) { DelegationState.Done => "finished the job you delegated", DelegationState.Failed => "could not finish the job you delegated", + DelegationState.Cancelled => "job cancelled", + DelegationState.CancellationRequested => "cancellation requested", _ => "working on the job you delegated", }; diff --git a/src/MandoCode.Desktop/Services/PromptFileLookup.cs b/src/MandoCode.Desktop/Services/PromptFileLookup.cs new file mode 100644 index 0000000..362fdde --- /dev/null +++ b/src/MandoCode.Desktop/Services/PromptFileLookup.cs @@ -0,0 +1,36 @@ +using MandoCode.Services; + +namespace MandoCode.Desktop.Services; + +public sealed record PromptFileSnapshot(CommandSuggestion[] Items, bool Loading, bool Indexing); + +/// All potentially large result copies/projections stay off the UI thread. +/// Cancelling a caller drops its results, while the shared provider keeps warming its index. +public sealed class PromptFileLookup(FileAutocompleteProvider provider) +{ + private readonly object _gate = new(); + private List? _lastPaths; + private CommandSuggestion[]? _lastItems; + public Task GetAsync(string query, CancellationToken cancellation = default) + => Task.Run(() => + { + cancellation.ThrowIfCancellationRequested(); + var paths = provider.GetSuggestions(query); + lock (_gate) + { + // Index completion often leaves the root listing unchanged. Reuse the + // projection so the native list keeps its viewport and item identity. + if (_lastPaths is not null && paths.SequenceEqual(_lastPaths)) + return new PromptFileSnapshot(_lastItems!, provider.IsLoading, provider.IsIndexing); + var items = new CommandSuggestion[paths.Count]; + for (var i = 0; i < paths.Count; i++) + { + cancellation.ThrowIfCancellationRequested(); + var path = paths[i]; + items[i] = new() { Command = path, Description = path.EndsWith('/') ? "folder — select to drill in" : "file" }; + } + _lastPaths = paths; _lastItems = items; + return new PromptFileSnapshot(items, provider.IsLoading, provider.IsIndexing); + } + }, cancellation); +} diff --git a/src/MandoCode.Desktop/Services/PromptMentionPicker.cs b/src/MandoCode.Desktop/Services/PromptMentionPicker.cs new file mode 100644 index 0000000..db3e710 --- /dev/null +++ b/src/MandoCode.Desktop/Services/PromptMentionPicker.cs @@ -0,0 +1,64 @@ +using MandoCode.Services; +using System.Text.Json; + +namespace MandoCode.Desktop.Services; + +public enum MentionPickerTarget { Files, Agents } +public sealed record MentionFragment(int Start, int End, string Query); + +/// Caret-local mention state, independent of WinUI and background lookup timing. +public sealed class PromptMentionPicker +{ + private string? _prefix, _dismissedPrefix; + public MentionFragment? Fragment { get; private set; } + public MentionPickerTarget Target { get; private set; } + public long Revision { get; private set; } + public string Title => Target == MentionPickerTarget.Agents + ? "Agents — Press [Tab] for Files" : "Files — Press [Tab] for Agents"; + + public bool Update(string text, int caret) + { + caret = Math.Clamp(caret, 0, text.Length); + var prefix = text[..caret]; + var start = prefix.LastIndexOf('@'); + var query = start < 0 ? "" : prefix[(start + 1)..]; + var quoted = query.StartsWith('"'); + var valid = start >= 0 && (start == 0 || char.IsWhiteSpace(text[start - 1])) + && (quoted ? !query[1..].Contains('"') : !query.Any(char.IsWhiteSpace)); + if (!valid || prefix == _dismissedPrefix) + { + if (Fragment is not null) Revision++; + Fragment = null; Target = MentionPickerTarget.Files; _prefix = null; + if (!valid) _dismissedPrefix = null; + return false; + } + if (prefix != _prefix) Revision++; + if (Fragment is null || Fragment.Start != start) Target = MentionPickerTarget.Files; + var fragment = quoted ? query[1..] : query; + if (quoted) + { + try { fragment = JsonSerializer.Deserialize(query + "\"") ?? fragment; } + catch (JsonException) { } // A partially typed escape is still editable. + } + Fragment = new(start, caret, fragment); + _prefix = prefix; _dismissedPrefix = null; + return true; + } + + public bool Toggle() + { + if (Fragment is null) return false; + Target = Target == MentionPickerTarget.Files ? MentionPickerTarget.Agents : MentionPickerTarget.Files; + Revision++; + return true; + } + + public void Dismiss() + { + _dismissedPrefix = _prefix; _prefix = null; Fragment = null; + Target = MentionPickerTarget.Files; Revision++; + } + + public static string FileReference(string path, bool collidesWithAgent) + => FileReferenceToken.Format(collidesWithAgent && !path.Contains('/') && !path.Contains('\\') ? "./" + path : path); +} diff --git a/src/MandoCode.Desktop/Services/SessionAgentPeer.cs b/src/MandoCode.Desktop/Services/SessionAgentPeer.cs index 838ec56..f29bae7 100644 --- a/src/MandoCode.Desktop/Services/SessionAgentPeer.cs +++ b/src/MandoCode.Desktop/Services/SessionAgentPeer.cs @@ -5,12 +5,8 @@ namespace MandoCode.Desktop.Services; /// /// A live seen as something another agent can talk to. /// -/// A delegated turn is a FULL turn: the target answers with all of its own tools, exactly as -/// it would answer the user. It is not run with a reduced tool set, which means it can read, write -/// and run commands in its own project — and its own approval gates still stand in the way of the -/// dangerous ones, raising their dialogs in its own tab where they are attributable. The alternative -/// (a read-only delegated turn) would make the feature useless for the thing it is for: getting -/// another agent, who has the context, to actually help. +/// Ordinary delegated turns retain the target's tools and approvals. Read-only reviews run +/// under the shared engine's review scope, which blocks mutating tools for that peer turn. /// public sealed class SessionAgentPeer : IAgentPeer { diff --git a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs index 9b57dc2..b0f66f5 100644 --- a/src/MandoCode.Desktop/Services/SessionArchiveStore.cs +++ b/src/MandoCode.Desktop/Services/SessionArchiveStore.cs @@ -247,15 +247,20 @@ private static void DeleteFiles(string key) AgentConfigStore.Delete(key); } - private void Persist() + private void Persist() => TryPersist(); + + public bool TryPersist() { try { List copy; lock (_lock) copy = _items.ToList(); Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); - File.WriteAllText(StorePath, JsonSerializer.Serialize(copy)); + var temporary = StorePath + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(copy)); + File.Move(temporary, StorePath, overwrite: true); + return true; } - catch { /* persistence is best-effort; the in-memory index is still correct */ } + catch { return false; } } } diff --git a/src/MandoCode.Desktop/Services/SessionManager.cs b/src/MandoCode.Desktop/Services/SessionManager.cs index f31421a..bc59205 100644 --- a/src/MandoCode.Desktop/Services/SessionManager.cs +++ b/src/MandoCode.Desktop/Services/SessionManager.cs @@ -68,10 +68,10 @@ private string NextAgentName() => AgentCallsigns.Enabled ? AgentCallsigns.Next(_sessions.Select(s => s.Title)) : AgentNaming.NextFreeName(_sessions.Select(s => s.Title)); - public void Activate(AgentSession session) + public void Activate(AgentSession? session) { if (ReferenceEquals(Active, session)) return; - if (!_sessions.Contains(session)) return; + if (session != null && !_sessions.Contains(session)) return; Active = session; } diff --git a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs index 57dee3f..9ed5603 100644 --- a/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs +++ b/src/MandoCode.Desktop/Services/TranscriptHtmlBuilder.cs @@ -53,6 +53,10 @@ public string FromMarkdown(string markdown) public string UserEcho(string text) => $"
> {E(text)}
"; + /// Agent exchanges remain inspectable without repeating their full contents in chat. + public string AgentExchange(string title, string markdown) => + $"
{E(title)}
{FromMarkdown(markdown)}
"; + /// Null speaker keeps the classic MandoCode label — used by surfaces with no /// agent (the appearance preview). Per-agent callers pass the tab's name so the card /// agrees with what the system prompt told the model it's called. diff --git a/src/MandoCode.Desktop/Services/WorkspaceState.cs b/src/MandoCode.Desktop/Services/WorkspaceState.cs index ff6f36b..0b74b30 100644 --- a/src/MandoCode.Desktop/Services/WorkspaceState.cs +++ b/src/MandoCode.Desktop/Services/WorkspaceState.cs @@ -32,27 +32,47 @@ public static class WorkspaceState Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MandoCode.Desktop", "workspace.json"); - public static WorkspaceShape? TryLoad() + + public static DesktopWorkspaceState LoadAll() { - try - { - if (!File.Exists(StorePath)) return null; - var shape = JsonSerializer.Deserialize(File.ReadAllText(StorePath)); - return shape is { Tabs.Count: > 0 } ? shape : null; - } - catch + try { return Parse(File.ReadAllText(StorePath)); } + catch { return DefaultState(); } + } + + // Pure parser shared with migration tests. Old single-workspace files become Main. + public static DesktopWorkspaceState Parse(string json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.TryGetProperty("Workspaces", out _)) { - return null; + var state = JsonSerializer.Deserialize(json); + if (state is { Version: 1, Workspaces.Count: > 0 } + && state.Workspaces.All(w => !string.IsNullOrWhiteSpace(w.Id) + && !string.IsNullOrWhiteSpace(w.Name) && w.Shape?.Tabs != null) + && state.Workspaces.Select(w => w.Id).Distinct().Count() == state.Workspaces.Count) + return state; + throw new InvalidDataException("Invalid workspace state."); } + var legacy = JsonSerializer.Deserialize(json); + if (legacy?.Tabs == null) throw new InvalidDataException("Invalid legacy workspace."); + return new(1, "main", new() { new("main", "Main", legacy) }); } - public static void Save(WorkspaceShape shape) + private static DesktopWorkspaceState DefaultState() => new(1, "main", + new() { new("main", "Main", new(new(), 0)) }); + + public static void SaveAll(DesktopWorkspaceState state) { try { Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!); - File.WriteAllText(StorePath, JsonSerializer.Serialize(shape)); + var temporary = StorePath + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(state)); + File.Move(temporary, StorePath, overwrite: true); } - catch { /* best-effort */ } + catch { /* best-effort; atomic replacement keeps the previous valid state */ } } } + +public sealed record NamedWorkspaceState(string Id, string Name, WorkspaceShape Shape); +public sealed record DesktopWorkspaceState(int Version, string ActiveWorkspaceId, List Workspaces); diff --git a/src/MandoCode.Desktop/ViewModels/ChatController.cs b/src/MandoCode.Desktop/ViewModels/ChatController.cs index 36441db..cc290eb 100644 --- a/src/MandoCode.Desktop/ViewModels/ChatController.cs +++ b/src/MandoCode.Desktop/ViewModels/ChatController.cs @@ -554,6 +554,8 @@ public async Task AnswerPeerAsync(string askedBy, string question, C using var lease = TryBeginTurn(); if (lease == null) return PeerAnswer.Busy(); + using var request = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + // Announced here, not by the caller: after the claim, so a question refused as busy leaves // no trace of an answer that never happened — and before the turn streams, so it reads in // the order it occurred rather than trailing the reply it prompted. @@ -577,11 +579,13 @@ public async Task AnswerPeerAsync(string askedBy, string question, C // is structural rather than a matter of interpretation. var framing = PeerTurnPolicy.BuildFraming(askedBy); + if (_inbox != null) framing += "\n\n" + AgentInbox.Format(_inbox.Drain()); + Volatile.Write(ref _requestCts, request); try { return PeerTurnPolicy.Classify( - await _streamer.StreamAsync(envelope, cancellationToken, framing)); + await _streamer.StreamAsync(envelope, request.Token, framing)); } catch (OperationCanceledException) { @@ -591,6 +595,10 @@ public async Task AnswerPeerAsync(string askedBy, string question, C { return PeerAnswer.Failed($"could not answer: {ex.Message}"); } + finally + { + Interlocked.CompareExchange(ref _requestCts, null, request); + } } public async Task SubmitAsync(string input, string? browserContext = null) @@ -679,7 +687,8 @@ public async Task SubmitAsync(string input, string? browserContext = null) public void CancelActiveRequest() { var cts = _requestCts; - if (cts != null && !cts.IsCancellationRequested) cts.Cancel(); + try { if (cts != null && !cts.IsCancellationRequested) cts.Cancel(); } + catch (ObjectDisposedException) { /* The turn finished between reading and cancelling. */ } } // ============================================================ @@ -778,32 +787,28 @@ private List ExtractAgentMentions(string input, out string remaining remaining = input; if (_agents == null) return found; - // Callsigns are single words, so only bare tokens are considered — anything carrying a - // path separator or a dot is a file reference and is left for the loop below. - remaining = Regex.Replace(input, @"@([A-Za-z][\w-]*)\b", m => + // Use the same token grammar as the picker, including quoted names and file paths. + foreach (var token in FileReferenceToken.Paths(input)) { - var agent = _agents.Resolve(m.Groups[1].Value); - if (agent == null || _selfKey == agent.Key) return m.Value; - if (seen.Add(agent.Key)) found.Add(agent); - return m.Value; // the mention stays in the user's text; only the file lookup is skipped - }); + if (token.Contains('/') || token.Contains('\\') || token.Contains('.')) continue; + var agent = _agents.Resolve(token); + if (agent is not null && agent.Key != _selfKey && seen.Add(agent.Key)) found.Add(agent); + } return found; } private string ProcessFileReferences(string input) { - var pattern = @"@([\w.\-/\\]+[\w.])"; - var matches = Regex.Matches(input, pattern); - if (matches.Count == 0) return input; + var paths = FileReferenceToken.Paths(input).ToArray(); + if (paths.Length == 0) return input; var contextBlocks = new StringBuilder(); var referencedFiles = new HashSet(); int totalExpansionChars = 0; int skippedForBudget = 0; - foreach (Match match in matches) + foreach (var filePath in paths) { - var filePath = match.Groups[1].Value; if (!referencedFiles.Add(filePath)) continue; // An open agent's callsign is not a missing file. Skip it here; the mention is carried