Lagebild, Stufe 0: Teamuebersicht in der WebUI (#711)

Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es,
auf einen Blick. Stufe 0 kommt ohne Einheiten aus; mit #683 werden die Agenten
zu aufklappbaren Containern, und die Ansicht faellt mit #688 zusammen.

Kern:
- AgentActivity: fuehrt die laufenden Laeufe (Beginn, Quelle, Aufgabe,
  Aufrufer) und die letzten Verbindungen (begrenzt auf 500), meldet Start,
  Ende und Verbindung als Ereignis. Vorher war nur ein Lauf ueber ChatAsync
  "laufend"; ein frischer Lauf (@new) war unsichtbar, und niemand hielt fest,
  wer wen gerufen hatte.
- Die Engine meldet beide Einstiege an; "arbeitet" beginnt hinter dem
  Agententor.

Anwendung:
- TeamOverview stellt den Lagebericht zusammen. Die Zustaende entscheidet der
  Code aus erfassten Tatsachen: laufender Lauf, offene Freigabe, Status des
  letzten Belegs, Laufzeit gegen den Median des Agenten (haengt ab dem
  Dreifachen, fruehestens nach 5 Minuten, ohne Verlauf nach 15). Rangfolge:
  haengt, wartet auf Freigabe, arbeitet; Problem und Budget nur, solange
  nichts Neues laeuft.

Web:
- GET /api/team, Ereignis "activity" (started, finished, link); das Ereignis
  "run" traegt jetzt die Agenten-Id.
- Ansicht #/team mit Cytoscape.js und ELK (bei Bedarf geladen): Layout aus
  der Struktur statt von Hand, Farbe und Symbol je Zustand, Kanten verblassen
  nach 30 s, Klick oeffnet eine Seitenleiste (laufende Laeufe, Freigaben,
  letzter Lauf im Wortlaut) mit "Freigaben oeffnen" vorne, danach Chat und
  Laeufe. Sprachpakete en/de.
- Ein Layout, das lief, solange die Flaeche noch 0 px breit war, wird neu
  gerechnet, sobald sie eine Breite hat (im Pruefgestell gefunden).

Geprueft im Browser mit einem Pruefgestell (API und Ereignisstrom durch
Attrappen ersetzt): sieben Agenten in allen Zustaenden, Seitenleiste,
Live-Kante und Verblassen.

Tests: AgentActivityTests (8), TeamOverviewTests (14). 1.283 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:34:46 +02:00
co-authored by Claude Opus 5.5
parent d587dc3f20
commit f627918729
17 changed files with 1153 additions and 1 deletions
+3
View File
@@ -113,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
@@ -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;
/// <summary>What an agent is doing, as the team overview shows it (#711).</summary>
public enum TeamState
{
/// <summary>Nothing running, nothing open, the last run went fine (or there was none).</summary>
Idle,
/// <summary>At least one run in progress.</summary>
Working,
/// <summary>A run has taken much longer than this agent's runs usually take.</summary>
Stuck,
/// <summary>Tool calls of this agent wait for a human decision.</summary>
WaitingForApproval,
/// <summary>The last run failed, hit a limit or found the target busy.</summary>
Problem,
/// <summary>The last run was refused because the budget is used up.</summary>
BudgetExhausted
}
/// <summary>One agent in the team overview.</summary>
public sealed record TeamMember(
string AgentId,
string DisplayName,
string Model,
TeamState State,
IReadOnlyList<ActiveRun> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
/// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck.
///
/// <para><b>Detecting is code, interpreting is model</b> (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.</para>
///
/// <para><b>Order of states.</b> 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.</para>
/// </summary>
public sealed class TeamOverview(
AgentEngine? engine,
StagingService? staging,
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null)
{
/// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
/// <summary>How long recent links stay in the snapshot — the overview fades them out.</summary>
public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
/// <summary>A run counts as stuck after this many times the agent's typical duration…</summary>
public const int StuckFactor = 3;
/// <summary>…but never before this — short runs vary a lot in relative terms.</summary>
public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
/// <summary>Without history, a run counts as stuck after this long.</summary>
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
public async Task<TeamSnapshot> 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<string, RunReceipt>(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<TeamMember>();
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);
}
/// <summary>
/// The state from the facts. Pure, so the rules can be tested without an instance.
/// </summary>
public static TeamState Classify(
IReadOnlyList<ActiveRun> 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
};
}
/// <summary>The agent's typical run duration — the median of its recent runs.</summary>
private async Task<TimeSpan> 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]);
}
}
@@ -0,0 +1,132 @@
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Activity;
/// <summary>A run that is in progress right now.</summary>
public sealed record ActiveRun(
string RunId,
string AgentId,
string Source,
string? TaskId,
string? CalledBy,
DateTime StartedAtUtc);
/// <summary>
/// One agent reaching another — for now a delegated run (<see cref="Kind"/> =
/// <c>delegation</c>). Meetings and escalations will add their own kinds.
/// </summary>
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
/// <summary>
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
///
/// <para><b>Why a component of its own.</b> The engine already tracked running chats, but
/// only those that went through <c>ChatAsync</c>; a fresh run (<c>@new</c>) was invisible to
/// <c>IsRunning</c>. 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.</para>
///
/// <para><b>Memory only, and bounded.</b> This is the view of "now". What happened
/// yesterday is in the audit log; the time line (#711, later) replays it from there.</para>
/// </summary>
public sealed class AgentActivity
{
/// <summary>How many recent links are kept for a page that connects late.</summary>
public const int RecentLinkCapacity = 500;
private readonly Lock _lock = new();
private readonly Dictionary<string, ActiveRun> _active = new(StringComparer.Ordinal);
private readonly Queue<AgentLink> _recentLinks = new();
private readonly TimeProvider _clock;
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
public event Action<ActiveRun>? RunStarted;
/// <summary>A run ended — with the <c>AgentRunStatus</c> name, or <c>Aborted</c>.</summary>
public event Action<ActiveRun, string>? RunFinished;
public event Action<AgentLink>? LinkObserved;
/// <summary>
/// Announces a run. Dispose the handle when it ends; call <see cref="RunHandle.Complete"/>
/// first to report how. A delegated run (<paramref name="chain"/> deeper than its root)
/// also records the link from the caller.
/// </summary>
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);
}
/// <summary>The runs in progress right now.</summary>
public IReadOnlyList<ActiveRun> ActiveRuns()
{
lock (_lock)
return [.. _active.Values];
}
/// <summary>Links observed within <paramref name="window"/>, oldest first.</summary>
public IReadOnlyList<AgentLink> 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));
}
/// <summary>A subscriber that throws must not break the run it is watching.</summary>
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;
}
/// <summary>How the run ended. Without it, disposal reports <c>Aborted</c>.</summary>
public void Complete(string status) => _status = status;
public void Dispose() => _owner.End(_run, _status);
}
}
@@ -41,6 +41,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// </summary>
public BudgetGuard? BudgetGuard => _budgetGuard;
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
public Activity.AgentActivity Activity { get; } = new();
/// <summary>
/// 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);
@@ -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; }
}
@@ -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(); });
@@ -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",
@@ -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",
@@ -55,6 +55,12 @@
</svg>
<span class="nav-label" data-i18n="nav.agents">Agents</span>
</a>
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
</svg>
<span class="nav-label" data-i18n="nav.team">Team overview</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
@@ -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));
}
@@ -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;
}
+25
View File
@@ -125,6 +125,31 @@ public sealed record TaskDto(
/// </summary>
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
/// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
/// </summary>
public sealed record TeamMemberDto(
string AgentId,
string DisplayName,
string Model,
string State,
IReadOnlyList<ActiveRunDto> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
/// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
/// <summary>#711: One agent reaching another.</summary>
public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
+15
View File
@@ -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
{
@@ -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();
@@ -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;
@@ -0,0 +1,82 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #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.
/// </summary>
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);
}
@@ -0,0 +1,143 @@
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Activity;
/// <summary>
/// #711: Who is working right now and who reached whom — the live side of the team overview.
/// </summary>
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<string>();
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<ActiveRun>();
var finished = new List<string>();
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<ActiveRun>();
fixture.Engine.Activity.RunStarted += started.Add;
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
}
}