diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index c9430b2..1cd4d9a 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -113,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents, LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks")); + /// #711: The team overview. Stateless, like . + public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents); + public TelegramClientManager? TelegramClient { get; private init; } public OpenRouterStatusService? Status { get; private init; } diff --git a/src/ClawdDotNet.App/Services/TeamOverview.cs b/src/ClawdDotNet.App/Services/TeamOverview.cs new file mode 100644 index 0000000..929ba6d --- /dev/null +++ b/src/ClawdDotNet.App/Services/TeamOverview.cs @@ -0,0 +1,170 @@ +using ClawdDotNet.Core.Accounting; +using ClawdDotNet.Core.Activity; +using ClawdDotNet.Core.Audit; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Staging; + +namespace ClawdDotNet.App.Services; + +/// What an agent is doing, as the team overview shows it (#711). +public enum TeamState +{ + /// Nothing running, nothing open, the last run went fine (or there was none). + Idle, + + /// At least one run in progress. + Working, + + /// A run has taken much longer than this agent's runs usually take. + Stuck, + + /// Tool calls of this agent wait for a human decision. + WaitingForApproval, + + /// The last run failed, hit a limit or found the target busy. + Problem, + + /// The last run was refused because the budget is used up. + BudgetExhausted +} + +/// One agent in the team overview. +public sealed record TeamMember( + string AgentId, + string DisplayName, + string Model, + TeamState State, + IReadOnlyList Runs, + int PendingApprovals, + string? LastRunStatus, + DateTime? LastRunAtUtc, + string? LastRunResult, + long TypicalRunMs); + +/// The whole picture at one moment: every agent, and who reached whom recently. +public sealed record TeamSnapshot(IReadOnlyList Members, IReadOnlyList RecentLinks, DateTime AtUtc); + +/// +/// #711: The team overview — who is working, who reached whom, where something is stuck. +/// +/// Detecting is code, interpreting is model (Organisation-Konzept, 10a). Every +/// state here comes from a fact the instance recorded: a run in progress, an open staged +/// call, the status of the last receipt. Nothing is judged by a model, so the picture is +/// the same for everyone who looks and costs nothing to draw. +/// +/// Order of states. When several apply, the one that needs someone first wins: +/// stuck, then waiting for approval, then working; a problem or an exhausted budget only +/// shows while nothing new is running — a new run is the attempt to get past it. +/// +public sealed class TeamOverview( + AgentEngine? engine, + StagingService? staging, + IAuditRepository? audit, + IUsageRepository? usage, + Func> agents, + TimeProvider? clock = null) +{ + /// How far back the last receipt of each agent is looked for. + public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3); + + /// How long recent links stay in the snapshot — the overview fades them out. + public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10); + + /// A run counts as stuck after this many times the agent's typical duration… + public const int StuckFactor = 3; + + /// …but never before this — short runs vary a lot in relative terms. + public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5); + + /// Without history, a run counts as stuck after this long. + public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15); + + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + + public async Task SnapshotAsync(CancellationToken ct) + { + var now = _clock.GetUtcNow().UtcDateTime; + var configured = agents(); + + var active = engine?.Activity.ActiveRuns() ?? []; + var links = engine?.Activity.RecentLinks(LinkWindow) ?? []; + + var pending = staging is null ? [] : await staging.ListPendingAsync(ct); + var pendingByAgent = pending + .GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + var lastReceipt = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (audit is not null) + { + var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct); + foreach (var receipt in receipts) + { + if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt) + lastReceipt[receipt.AgentId] = receipt; + } + } + + var members = new List(); + foreach (var agent in configured) + { + var runs = active + .Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.StartedAtUtc) + .ToList(); + var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId); + lastReceipt.TryGetValue(agent.AgentId, out var last); + var typical = await TypicalRunAsync(agent.AgentId, ct); + + var state = Classify(runs, approvals, last?.Status, typical, now); + members.Add(new TeamMember( + agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals, + last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds)); + } + + return new TeamSnapshot(members, links, now); + } + + /// + /// The state from the facts. Pure, so the rules can be tested without an instance. + /// + public static TeamState Classify( + IReadOnlyList runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc) + { + if (runs.Count > 0) + { + var limit = typicalRun > TimeSpan.Zero + ? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks)) + : StuckWithoutHistory; + if (runs.Any(r => nowUtc - r.StartedAtUtc > limit)) + return TeamState.Stuck; + } + + if (pendingApprovals > 0) + return TeamState.WaitingForApproval; + + if (runs.Count > 0) + return TeamState.Working; + + return lastRunStatus switch + { + nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted, + nameof(AgentRunStatus.Failed) + or nameof(AgentRunStatus.LoopLimitExceeded) + or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem, + _ => TeamState.Idle + }; + } + + /// The agent's typical run duration — the median of its recent runs. + private async Task TypicalRunAsync(string agentId, CancellationToken ct) + { + if (usage is null) + return TimeSpan.Zero; + + var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct); + var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList(); + return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]); + } +} diff --git a/src/ClawdDotNet.Core/Activity/AgentActivity.cs b/src/ClawdDotNet.Core/Activity/AgentActivity.cs new file mode 100644 index 0000000..cb27828 --- /dev/null +++ b/src/ClawdDotNet.Core/Activity/AgentActivity.cs @@ -0,0 +1,132 @@ +using ClawdDotNet.Core.Tools; + +namespace ClawdDotNet.Core.Activity; + +/// A run that is in progress right now. +public sealed record ActiveRun( + string RunId, + string AgentId, + string Source, + string? TaskId, + string? CalledBy, + DateTime StartedAtUtc); + +/// +/// One agent reaching another — for now a delegated run ( = +/// delegation). Meetings and escalations will add their own kinds. +/// +public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc); + +/// +/// #711: Who is working right now, and who reached whom — the live side of the team overview. +/// +/// Why a component of its own. The engine already tracked running chats, but +/// only those that went through ChatAsync; a fresh run (@new) was invisible to +/// IsRunning. And nothing recorded that agent A had called agent B — the delegation +/// chain knew it for the length of the call and then forgot. Here both are kept and +/// announced, independent of which engine path a run takes. +/// +/// Memory only, and bounded. This is the view of "now". What happened +/// yesterday is in the audit log; the time line (#711, later) replays it from there. +/// +public sealed class AgentActivity +{ + /// How many recent links are kept for a page that connects late. + public const int RecentLinkCapacity = 500; + + private readonly Lock _lock = new(); + private readonly Dictionary _active = new(StringComparer.Ordinal); + private readonly Queue _recentLinks = new(); + private readonly TimeProvider _clock; + + public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System; + + public event Action? RunStarted; + + /// A run ended — with the AgentRunStatus name, or Aborted. + public event Action? RunFinished; + + public event Action? LinkObserved; + + /// + /// Announces a run. Dispose the handle when it ends; call + /// first to report how. A delegated run ( deeper than its root) + /// also records the link from the caller. + /// + public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain) + { + var now = _clock.GetUtcNow().UtcDateTime; + var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null; + var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now); + + AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now); + + lock (_lock) + { + _active[runId] = run; + if (link is not null) + { + _recentLinks.Enqueue(link); + while (_recentLinks.Count > RecentLinkCapacity) + _recentLinks.Dequeue(); + } + } + + Raise(() => RunStarted?.Invoke(run)); + if (link is not null) + Raise(() => LinkObserved?.Invoke(link)); + + return new RunHandle(this, run); + } + + /// The runs in progress right now. + public IReadOnlyList ActiveRuns() + { + lock (_lock) + return [.. _active.Values]; + } + + /// Links observed within , oldest first. + public IReadOnlyList RecentLinks(TimeSpan window) + { + var since = _clock.GetUtcNow().UtcDateTime - window; + lock (_lock) + return [.. _recentLinks.Where(l => l.AtUtc >= since)]; + } + + private void End(ActiveRun run, string status) + { + lock (_lock) + { + if (!_active.Remove(run.RunId)) + return; + } + + Raise(() => RunFinished?.Invoke(run, status)); + } + + /// A subscriber that throws must not break the run it is watching. + private static void Raise(Action raise) + { + try { raise(); } + catch { /* observers are best effort */ } + } + + public sealed class RunHandle : IDisposable + { + private readonly AgentActivity _owner; + private readonly ActiveRun _run; + private string _status = "Aborted"; + + internal RunHandle(AgentActivity owner, ActiveRun run) + { + _owner = owner; + _run = run; + } + + /// How the run ended. Without it, disposal reports Aborted. + public void Complete(string status) => _status = status; + + public void Dispose() => _owner.End(_run, _status); + } +} diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index b7c44a6..496f81a 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -41,6 +41,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// public BudgetGuard? BudgetGuard => _budgetGuard; + /// #711: Who is working right now and who reached whom — for the team overview. + public Activity.AgentActivity Activity { get; } = new(); + /// /// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8). /// A→B ist die Tiefe 1, A→B→C die Tiefe 2. @@ -179,8 +182,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut var runId = Guid.NewGuid().ToString("N"); var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId); + using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain); var recorder = CreateRecorder(agentConfig); var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder); + activity.Complete(result.Status.ToString()); await RecordUsageAsync(agentConfig, result); await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId); await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId); @@ -498,8 +503,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut try { + // #711: "working" starts past the gate — waiting for it is not working. + using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain); var recorder = CreateRecorder(agentConfig); var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder); + activity.Complete(result.Status.ToString()); await RecordUsageAsync(agentConfig, result); await RecordReceiptAsync(runId, agentConfig, result, source, taskId); await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index c781a6d..10c146d 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -1721,3 +1721,77 @@ div.klein, td.klein { font-size: 12px; } + +/* ─── Team overview (#711) ─── */ + +.team-ansicht { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +.team-zusammenfassung { + flex-wrap: wrap; + min-height: 24px; +} + +.team-flaeche { + display: flex; + gap: 12px; + flex: 1; + min-height: 420px; +} + +.team-graph { + flex: 1; + min-width: 0; + min-height: 420px; + background: var(--surface-subtil); + border: 1px solid var(--divider); +} + +.team-seitenleiste { + width: 320px; + flex-shrink: 0; + padding: 14px; + background: var(--surface); + border: 1px solid var(--divider); + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; +} + +.team-seitenleiste-kopf { + justify-content: space-between; +} + +.team-seitenleiste h3 { + margin: 8px 0 0; +} + +.team-ergebnis { + white-space: pre-wrap; + word-break: break-word; + max-height: 160px; + overflow-y: auto; + font-family: var(--schrift-mono); +} + +.team-aktionen { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.team-legende { + flex-wrap: wrap; + gap: 16px; +} + +@media (max-width: 800px) { + .team-flaeche { flex-direction: column; } + .team-seitenleiste { width: auto; } +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index 5f0f203..efe8187 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js"; import { renderUsageTimeline } from "./views/usage-timeline.js"; import { renderRunDetail } from "./views/run-detail.js"; import { renderPromptHistory } from "./views/prompt-history.js"; +import { renderTeam, leaveTeam } from "./views/team.js"; // ─── 1. Routen registrieren ─── function verlasseAnsicht() { + leaveTeam(); trenneLogStream(); trenneAgentenChatsStream(); stoppeInstanzenPolling(); @@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); }); registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); }); registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); }); +registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); }); registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); }); registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); }); registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); }); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 1755eb9..30a58ba 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -67,6 +67,28 @@ "nav.chat": "Chat", "nav.agents": "Agenten", "nav.tasks": "Aufgaben", + "nav.team": "Teamübersicht", + "team.title": "Teamübersicht", + "team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.", + "team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.", + "team.empty": "Diese Instanz hat keine Agenten.", + "team.legendLink": "hat erreicht (verblasst nach 30 s)", + "team.state.Idle": "bereit", + "team.state.Working": "arbeitet", + "team.state.Stuck": "hängt", + "team.state.WaitingForApproval": "wartet auf Freigabe", + "team.state.Problem": "Problem", + "team.state.BudgetExhausted": "Budget erschöpft", + "team.running": "Läuft gerade", + "team.since": "seit {time}", + "team.task": "Aufgabe {id}", + "team.calledBy": "gerufen von {agent}", + "team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.", + "team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe", + "team.lastRun": "Letzter Lauf", + "team.openApprovals": "Freigaben öffnen", + "team.openChat": "Chat öffnen", + "team.openRuns": "Läufe anzeigen", "nav.services": "Dienste", "nav.group.analytics": "Analyse", "nav.usage": "Tokennutzung", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 220b99a..ee7f842 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -67,6 +67,28 @@ "nav.chat": "Chat", "nav.agents": "Agents", "nav.tasks": "Tasks", + "nav.team": "Team overview", + "team.title": "Team overview", + "team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.", + "team.noInstance": "No instance selected. Choose an active instance in the header.", + "team.empty": "No agents in this instance.", + "team.legendLink": "reached (fades after 30 s)", + "team.state.Idle": "idle", + "team.state.Working": "working", + "team.state.Stuck": "stuck", + "team.state.WaitingForApproval": "waiting for approval", + "team.state.Problem": "problem", + "team.state.BudgetExhausted": "budget exhausted", + "team.running": "Running now", + "team.since": "since {time}", + "team.task": "task {id}", + "team.calledBy": "called by {agent}", + "team.stuckHint": "Runs of this agent usually take about {minutes} min.", + "team.approvals": "{n} tool call(s) waiting for approval", + "team.lastRun": "Last run", + "team.openApprovals": "Open approvals", + "team.openChat": "Open chat", + "team.openRuns": "Show runs", "nav.services": "Services", "nav.group.analytics": "Analytics", "nav.usage": "Token Usage", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html index 0f99b2b..34b1ee7 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html +++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html @@ -55,6 +55,12 @@ Agents + + + + + Team overview + diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js index 4e71d3d..fd66df1 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js @@ -93,7 +93,7 @@ export function verbindeStrom() { meldeStatus(false); }; - const bekannteEreignisse = ["chat", "step", "run", "instance", "status"]; + const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"]; for (const art of bekannteEreignisse) { strom.addEventListener(art, (e) => verteileEreignis(art, e.data)); } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js new file mode 100644 index 0000000..c332f81 --- /dev/null +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/team.js @@ -0,0 +1,429 @@ +// ClawdDotNet — Team overview (#711) +// +// Who is working, who reached whom in the last seconds, and where something is stuck — at a +// glance, like looking across an office. Stage 0: the agents of the active instance and the +// live links between them. With units (#683) they become collapsible containers. +// +// Three rules from the concept (Organisation-Konzept, 10a): +// - The layout comes from the structure, not from the hand: no free node editor. +// - The state is decided by the instance (code), the page only draws it. +// - A click opens a side panel, not the chat — most problems are solved by an approval. + +import { api, holeAktiveInstanz } from "../modules/api.js"; +import { erstelleElement, erstellePlakette, formatiereDatumZeit } from "../modules/dom.js"; +import { abonnieren } from "../modules/events.js"; +import { t } from "../modules/i18n.js"; + +// Loaded on demand: pages without a graph do not pay for ~2 MB of script. +const LIBRARIES = [ + "vendor/cytoscape/cytoscape.min.js", + "vendor/elkjs/elk.bundled.js", + "vendor/cytoscape-elk/cytoscape-elk.js" +]; + +/** How long a link stays visible after it was observed; it fades out over this time. */ +const LINK_VISIBLE_MS = 30_000; + +/** How often the picture is refreshed without an event — for "stuck" and approvals. */ +const REFRESH_MS = 30_000; + +// Every state has a colour AND a symbol: colour alone is not enough. +const STATES = { + Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" }, + Working: { symbol: "▶", tone: "erfolg", colour: "--accent" }, + Stuck: { symbol: "⧗", tone: "gefahr", colour: "--gefahr" }, + WaitingForApproval: { symbol: "✋", tone: "warnung", colour: "--warnung" }, + Problem: { symbol: "⚠", tone: "gefahr", colour: "--gefahr" }, + BudgetExhausted: { symbol: "$", tone: "warnung", colour: "--warnung" } +}; + +let librariesLoaded = null; +let cy = null; +let snapshot = null; +let selectedAgent = null; +let panel = null; +let summary = null; +let unsubscribe = null; +let refreshTimer = null; +let fadeTimer = null; +let refreshPending = null; +let resizeObserver = null; + +/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */ +let layoutWidth = 0; + +// ─── Loading ─── + +function loadScript(src) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.onload = resolve; + script.onerror = () => reject(new Error(`Could not load ${src}`)); + document.head.append(script); + }); +} + +function loadLibraries() { + librariesLoaded ??= (async () => { + // In order: cytoscape-elk registers against the globals of the two before it. + for (const src of LIBRARIES) await loadScript(src); + window.cytoscape.use(window.cytoscapeElk); + })(); + return librariesLoaded; +} + +function token(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888"; +} + +// ─── View ─── + +export async function renderTeam() { + const container = document.getElementById("ansicht-inhalt"); + if (!container) return; + leaveTeam(); + + const layout = erstelleElement("div", { klasse: "team-ansicht" }); + layout.append( + erstelleElement("h1", { text: t("team.title") }), + erstelleElement("p", { klasse: "gedaempft", text: t("team.subtitle") }) + ); + + summary = erstelleElement("div", { klasse: "team-zusammenfassung gruppe-horizontal" }); + const canvas = erstelleElement("div", { klasse: "team-graph", id: "team-graph" }); + panel = erstelleElement("aside", { klasse: "team-seitenleiste" }); + panel.hidden = true; + + layout.append(summary, erstelleElement("div", { klasse: "team-flaeche" }, canvas, panel), buildLegend()); + container.replaceChildren(layout); + + if (!holeAktiveInstanz()) { + canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") })); + return; + } + + canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") })); + + try { + await loadLibraries(); + snapshot = await api("/team"); + } catch (err) { + canvas.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })); + return; + } + + canvas.replaceChildren(); + drawGraph(canvas); + + unsubscribe = abonnieren("activity", onActivity); + refreshTimer = setInterval(refresh, REFRESH_MS); + fadeTimer = setInterval(fadeLinks, 1_000); +} + +/** Called when the route changes: stop listening, stop timers, free the graph. */ +export function leaveTeam() { + unsubscribe?.(); + unsubscribe = null; + clearInterval(refreshTimer); + clearInterval(fadeTimer); + clearTimeout(refreshPending); + refreshTimer = fadeTimer = refreshPending = null; + resizeObserver?.disconnect(); + resizeObserver = null; + cy?.destroy(); + cy = null; + selectedAgent = null; +} + +// ─── Graph ─── + +function drawGraph(canvas) { + cy = window.cytoscape({ + container: canvas, + elements: [], + wheelSensitivity: 0.2, + minZoom: 0.3, + maxZoom: 2.5, + // Nodes are not dragged: the layout comes from the structure, not the hand. + autoungrabify: true, + style: [ + { + selector: "node", + style: { + shape: "rectangle", + width: 190, + height: 58, + "background-color": token("--surface"), + "border-width": 2, + "border-color": "data(colour)", + label: "data(label)", + "text-wrap": "wrap", + "text-max-width": 176, + "text-valign": "center", + "text-halign": "center", + "font-family": "Barlow, sans-serif", + "font-size": 13, + color: token("--text") + } + }, + { selector: "node.aktiv", style: { "border-width": 4 } }, + { selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } }, + { + selector: "edge", + style: { + width: 3, + "line-color": token("--accent"), + "target-arrow-color": token("--accent"), + "target-arrow-shape": "triangle", + "curve-style": "bezier", + opacity: "data(opacity)" + } + } + ] + }); + + cy.on("tap", "node", (event) => openPanel(event.target.id())); + cy.on("tap", (event) => { if (event.target === cy) closePanel(); }); + + applySnapshot(true); + for (const link of snapshot.links ?? []) addLink(link); + observeSize(canvas); +} + +/** + * The area can still be 0 px wide when the graph is first drawn — the page is being laid out + * around it. ELK then packs everything into one column, and nothing can be fitted. So: lay + * out again once the area has a width, and fit again whenever its size changes (the side + * panel opening, the window resizing). + */ +function observeSize(canvas) { + resizeObserver = new ResizeObserver(() => { + if (!cy) return; + cy.resize(); + if (layoutWidth === 0 && canvas.clientWidth > 0) runLayout(); + else cy.fit(undefined, 30); + }); + resizeObserver.observe(canvas); +} + +function runLayout() { + const area = cy.container(); + layoutWidth = area.clientWidth; + const layout = cy.layout({ + name: "elk", + fit: false, + // Stage 0 has no structure yet, so the agents are packed evenly into the shape of + // the area. rectpacking keeps their order, so a box does not jump around when a + // state changes. With units (#683) this becomes a layered layout of nested containers. + elk: { + algorithm: "rectpacking", + "elk.spacing.nodeNode": 40, + "elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0 + ? Math.max(0.5, area.clientWidth / area.clientHeight) + : 2 + } + }); + // ELK computes asynchronously; fit once the positions are there. + layout.one("layoutstop", () => cy?.fit(undefined, 30)); + layout.run(); +} + +function nodeLabel(member) { + const state = STATES[member.state] ?? STATES.Idle; + const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : ""; + return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`; +} + +function applySnapshot(relayout) { + if (!cy || !snapshot) return; + + const present = new Set(); + for (const member of snapshot.members) { + present.add(member.agentId); + const state = STATES[member.state] ?? STATES.Idle; + const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) }; + const node = cy.getElementById(member.agentId); + if (node.nonempty()) { + node.data(data); + } else { + cy.add({ group: "nodes", data }); + relayout = true; + } + cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0); + } + + cy.nodes().filter((n) => !present.has(n.id())).remove(); + + if (relayout) runLayout(); + + renderSummary(); + if (selectedAgent) openPanel(selectedAgent); +} + +// ─── Live ─── + +function forActiveInstance(data) { + // The manager merges the streams of all instances and stamps each event. + return !data?.instanz || data.instanz === holeAktiveInstanz(); +} + +function onActivity(data) { + if (!cy || !forActiveInstance(data)) return; + + if (data.kind === "link" && data.link) addLink(data.link); + + // Start and end change states; ask the instance instead of guessing here. + clearTimeout(refreshPending); + refreshPending = setTimeout(refresh, 400); +} + +async function refresh() { + if (!cy) return; + try { + snapshot = await api("/team"); + applySnapshot(false); + } catch { + // The next event or tick tries again; a failed refresh keeps the last picture. + } +} + +function addLink(link) { + if (!cy) return; + const age = Date.now() - new Date(link.atUtc).getTime(); + if (age > LINK_VISIBLE_MS) return; + if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return; + + // One edge per pair; a new contact renews it. + const id = `link:${link.from}->${link.to}`; + const existing = cy.getElementById(id); + const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 }; + if (existing.nonempty()) existing.data(data); + else cy.add({ group: "edges", data }); +} + +function fadeLinks() { + if (!cy) return; + const now = Date.now(); + cy.edges().forEach((edge) => { + const age = now - edge.data("seenAt"); + if (age > LINK_VISIBLE_MS) edge.remove(); + else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS)); + }); +} + +// ─── Summary and legend ─── + +function renderSummary() { + if (!summary || !snapshot) return; + const counts = {}; + for (const member of snapshot.members) counts[member.state] = (counts[member.state] ?? 0) + 1; + + const parts = Object.keys(STATES) + .filter((state) => counts[state]) + .map((state) => erstellePlakette(`${STATES[state].symbol} ${counts[state]} ${t(`team.state.${state}`)}`, STATES[state].tone)); + + summary.replaceChildren( + ...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })]) + ); +} + +function buildLegend() { + return erstelleElement( + "div", + { klasse: "team-legende gruppe-horizontal gedaempft text-klein" }, + ...Object.entries(STATES).map(([state, s]) => + erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })), + erstelleElement("span", { text: `→ ${t("team.legendLink")}` }) + ); +} + +// ─── Side panel ─── + +function openPanel(agentId) { + const member = snapshot?.members.find((m) => m.agentId === agentId); + if (!member || !panel) return; + selectedAgent = agentId; + cy?.getElementById(agentId).select(); + + const state = STATES[member.state] ?? STATES.Idle; + const rows = [ + erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" }, + erstelleElement("h2", { text: member.displayName || member.agentId }), + erstelleElement("button", { + klasse: "schaltflaeche flach klein", + text: "×", + title: t("common.close"), + onclick: closePanel + })), + erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }), + erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone) + ]; + + // Top layer: what is happening now. + if (member.runs.length) { + rows.push(erstelleElement("h3", { text: t("team.running") })); + for (const run of member.runs) { + const parts = [ + t("team.since", { time: formatiereDatumZeit(run.startedAtUtc) }), + run.source, + run.taskId ? t("team.task", { id: run.taskId }) : null, + run.calledBy ? t("team.calledBy", { agent: run.calledBy }) : null + ].filter(Boolean); + rows.push(erstelleElement("p", { klasse: "text-klein", text: parts.join(" · ") })); + } + if (member.state === "Stuck" && member.typicalRunMs > 0) { + rows.push(erstelleElement("p", { + klasse: "text-klein", + text: `⚠ ${t("team.stuckHint", { minutes: Math.max(1, Math.round(member.typicalRunMs / 60_000)) })}` + })); + } + } + + if (member.pendingApprovals > 0) { + rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: member.pendingApprovals })}` })); + } + + // Middle layer: how the last run ended. + rows.push(erstelleElement("h3", { text: t("team.lastRun") })); + if (member.lastRunStatus) { + rows.push(erstelleElement("p", { klasse: "text-klein" }, + erstellePlakette(member.lastRunStatus, member.lastRunStatus === "Completed" ? "erfolg" : "warnung"), + ` ${formatiereDatumZeit(member.lastRunAtUtc)}`)); + if (member.lastRunResult) { + rows.push(erstelleElement("p", { klasse: "text-klein team-ergebnis", text: member.lastRunResult })); + } + } else { + rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" })); + } + + // The actions — the approval first, it is what most problems need. + const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" }); + if (member.pendingApprovals > 0) { + actions.append(link(`#/staging`, t("team.openApprovals"), "primaer")); + } + actions.append( + link(`#/chat/${encodeURIComponent(member.agentId)}`, t("team.openChat")), + // Bottom layer: every run with its raw transcript. + link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns")) + ); + rows.push(actions); + + panel.replaceChildren(...rows); + panel.hidden = false; +} + +function link(href, text, style = "flach") { + return erstelleElement("button", { + type: "button", + klasse: `${style} klein`, + text, + onclick: () => { window.location.hash = href; } + }); +} + +function closePanel() { + selectedAgent = null; + cy?.elements().unselect(); + if (panel) panel.hidden = true; +} diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 477a21a..f00057f 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -125,6 +125,31 @@ public sealed record TaskDto( /// public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver); +/// #711: The team overview — every agent and who reached whom recently. +public sealed record TeamSnapshotDto(IReadOnlyList Members, IReadOnlyList Links, DateTime AtUtc); + +/// +/// #711: One agent in the team overview. is one of Idle, Working, Stuck, +/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page. +/// +public sealed record TeamMemberDto( + string AgentId, + string DisplayName, + string Model, + string State, + IReadOnlyList Runs, + int PendingApprovals, + string? LastRunStatus, + DateTime? LastRunAtUtc, + string? LastRunResult, + long TypicalRunMs); + +/// #711: A run in progress. +public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc); + +/// #711: One agent reaching another. +public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc); + /// Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten. public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef); diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index 5d837ae..cf179e0 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -69,6 +69,21 @@ internal static class Mapping public static ChatEntryDto ToDto(this ChatEntry entry) => new(entry.Role, entry.Content, entry.Timestamp, entry.Source); + public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot) + => new( + snapshot.Members.Select(m => new TeamMemberDto( + m.AgentId, m.DisplayName, m.Model, m.State.ToString(), + m.Runs.Select(ToDto).ToList(), + m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(), + snapshot.RecentLinks.Select(ToDto).ToList(), + snapshot.AtUtc); + + public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run) + => new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc); + + public static AgentLinkDto ToDto(this Core.Activity.AgentLink link) + => new(link.From, link.To, link.Kind, link.AtUtc); + public static TaskDto ToDto(this TaskOverview overview) => overview.Task.ToDto() with { diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 92b2fe4..bc01936 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -31,6 +31,7 @@ internal static class ApiEndpoints api.MapUsage(); api.MapAnalytics(); api.MapLogs(); + api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); app.MapEvents(); app.MapControl(); diff --git a/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs index 5aef9ea..500a8a1 100644 --- a/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs @@ -71,12 +71,23 @@ internal static class EventEndpoints void OnRunCompleted(string model, Core.Engine.AgentRunResult result) => Publish("run", new { + agentId = result.AgentId, model, status = result.Status.ToString(), steps = result.StepCount, tokens = result.TokensUsed }); + // #711: the team overview — who starts, who stops, who reached whom. + void OnRunStarted(Core.Activity.ActiveRun run) + => Publish("activity", new { kind = "started", run = run.ToDto() }); + + void OnRunFinished(Core.Activity.ActiveRun run, string status) + => Publish("activity", new { kind = "finished", run = run.ToDto(), status }); + + void OnLink(Core.Activity.AgentLink link) + => Publish("activity", new { kind = "link", link = link.ToDto() }); + void OnInstanceChanged(InstanceChange change) => Publish("instance", new { kind = change.Kind.ToString(), id = change.Id }); @@ -88,6 +99,9 @@ internal static class EventEndpoints engine.OnChatEntryAdded += OnChat; engine.OnStepCompleted += OnStep; engine.OnRunCompleted += OnRunCompleted; + engine.Activity.RunStarted += OnRunStarted; + engine.Activity.RunFinished += OnRunFinished; + engine.Activity.LinkObserved += OnLink; } host.InstanceChanged += OnInstanceChanged; @@ -106,6 +120,9 @@ internal static class EventEndpoints e.OnChatEntryAdded -= OnChat; e.OnStepCompleted -= OnStep; e.OnRunCompleted -= OnRunCompleted; + e.Activity.RunStarted -= OnRunStarted; + e.Activity.RunFinished -= OnRunFinished; + e.Activity.LinkObserved -= OnLink; } host.InstanceChanged -= OnInstanceChanged; diff --git a/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs new file mode 100644 index 0000000..a84e280 --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/TeamOverviewTests.cs @@ -0,0 +1,82 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Activity; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #711: The rules that decide what the team overview shows for an agent. Every state comes +/// from a recorded fact, never from a model's judgement. +/// +public sealed class TeamOverviewTests +{ + private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc); + + private static ActiveRun RunningFor(TimeSpan duration) + => new("r", "worker", "task", null, null, Now - duration); + + [Fact] + public void Nothing_running_and_a_good_last_run_is_idle() + => TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle); + + [Fact] + public void An_agent_without_any_run_yet_is_idle() + => TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle); + + [Fact] + public void A_run_in_progress_is_working() + => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now) + .ShouldBe(TeamState.Working); + + [Fact] + public void A_run_far_beyond_the_usual_duration_is_stuck() + // Usually 2 minutes: three times that is 6, running for 7. + => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now) + .ShouldBe(TeamState.Stuck); + + [Fact] + public void Short_runs_get_a_floor_before_they_count_as_stuck() + // Usually 20 seconds: three times is one minute, but the floor is five. + => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now) + .ShouldBe(TeamState.Working); + + [Fact] + public void Without_history_a_run_is_stuck_only_after_a_long_while() + { + TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working); + TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck); + } + + [Fact] + public void An_open_approval_outranks_working() + // What the human can do something about comes first. + => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now) + .ShouldBe(TeamState.WaitingForApproval); + + [Fact] + public void Stuck_outranks_an_open_approval() + => TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now) + .ShouldBe(TeamState.Stuck); + + [Theory] + [InlineData("Failed")] + [InlineData("LoopLimitExceeded")] + [InlineData("TargetBusy")] + public void A_failed_last_run_is_a_problem(string status) + => TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem); + + [Fact] + public void A_refused_last_run_shows_the_exhausted_budget() + => TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted); + + [Fact] + public void A_new_run_hides_the_old_problem() + // A new run is the attempt to get past it; the problem shows again if it fails. + => TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now) + .ShouldBe(TeamState.Working); + + [Fact] + public void A_cancelled_run_is_not_a_problem() + // Cancelled means a human stopped it — that was a decision, not a fault. + => TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle); +} diff --git a/tests/ClawdDotNet.Core.Tests/Activity/AgentActivityTests.cs b/tests/ClawdDotNet.Core.Tests/Activity/AgentActivityTests.cs new file mode 100644 index 0000000..fced1ab --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Activity/AgentActivityTests.cs @@ -0,0 +1,143 @@ +using ClawdDotNet.Core.Activity; +using ClawdDotNet.Core.Tests.Infrastructure; +using ClawdDotNet.Core.Tools; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Activity; + +/// +/// #711: Who is working right now and who reached whom — the live side of the team overview. +/// +public sealed class AgentActivityTests +{ + private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero)); + + [Fact] + public void A_run_is_active_until_its_handle_is_disposed() + { + var activity = new AgentActivity(_clock); + + using (var run = activity.Begin("r1", "worker", "task", "t-1", null)) + { + var active = activity.ActiveRuns().ShouldHaveSingleItem(); + active.AgentId.ShouldBe("worker"); + active.TaskId.ShouldBe("t-1"); + active.CalledBy.ShouldBeNull(); + run.Complete("Completed"); + } + + activity.ActiveRuns().ShouldBeEmpty(); + } + + [Fact] + public void Start_and_end_are_announced_with_the_status() + { + var activity = new AgentActivity(_clock); + var log = new List(); + activity.RunStarted += r => log.Add($"start {r.AgentId}"); + activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}"); + + using (var run = activity.Begin("r1", "worker", "chat", null, null)) + run.Complete("LoopLimitExceeded"); + using (activity.Begin("r2", "worker", "chat", null, null)) { } + + log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]); + } + + [Fact] + public void A_delegated_run_records_who_called_whom() + { + var activity = new AgentActivity(_clock); + AgentLink? seen = null; + activity.LinkObserved += l => seen = l; + var chain = DelegationChain.Root("lead").Append("buyer"); + + using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { } + + seen.ShouldNotBeNull(); + seen.From.ShouldBe("lead"); + seen.To.ShouldBe("buyer"); + seen.Kind.ShouldBe("delegation"); + activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead"); + } + + [Fact] + public void Old_links_fall_out_of_the_recent_window() + { + var activity = new AgentActivity(_clock); + using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { } + + _clock.Advance(TimeSpan.FromMinutes(11)); + + activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty(); + } + + [Fact] + public void The_memory_of_links_is_bounded() + { + var activity = new AgentActivity(_clock); + var chain = DelegationChain.Root("lead").Append("buyer"); + + for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++) + using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { } + + activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity); + } + + [Fact] + public void A_subscriber_that_throws_does_not_break_the_run() + { + var activity = new AgentActivity(_clock); + activity.RunStarted += _ => throw new InvalidOperationException("broken page"); + activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page"); + + Should.NotThrow(() => + { + using var run = activity.Begin("r1", "worker", "chat", null, null); + run.Complete("Completed"); + }); + activity.ActiveRuns().ShouldBeEmpty(); + } + + // ─── through the engine ─── + + [Fact] + public async Task The_engine_announces_a_delegated_run_with_its_caller() + { + var fixture = new EngineFixture(); + fixture.AddAgent("lead"); + fixture.AddAgent("buyer"); + fixture.Client.RespondsWithText("Erledigt."); + + var started = new List(); + var finished = new List(); + fixture.Engine.Activity.RunStarted += started.Add; + fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status); + + var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default); + + result.Delivered.ShouldBeTrue(result.Error); + var run = started.ShouldHaveSingleItem(); + run.AgentId.ShouldBe("buyer"); + run.CalledBy.ShouldBe("lead"); + finished.ShouldBe(["Completed"]); + fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty(); + fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer"); + } + + [Fact] + public async Task A_fresh_run_is_visible_too() + { + // Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not. + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("worker"); + fixture.Client.RespondsWithText("Fertig."); + + var started = new List(); + fixture.Engine.Activity.RunStarted += started.Add; + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + started.ShouldHaveSingleItem().AgentId.ShouldBe("worker"); + } +}