feat: show per-agent activity statuses

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 12:58:21 +01:00
co-authored by Copilot
parent c5979d07bd
commit 9dfdb03f6f
10 changed files with 347 additions and 132 deletions
+10 -8
View File
@@ -42,6 +42,7 @@ export interface SessionEventRecord {
// New fields for 'agent-activity' events // New fields for 'agent-activity' events
activityType?: 'thinking' | 'tool-calling' | 'handoff' | 'completed'; activityType?: 'thinking' | 'tool-calling' | 'handoff' | 'completed';
agentId?: string;
agentName?: string; agentName?: string;
toolName?: string; toolName?: string;
} }
@@ -70,6 +71,7 @@ In the sidecar's turn-execution pipeline, emit a new JSON event type alongside t
"requestId": "…", "requestId": "…",
"sessionId": "…", "sessionId": "…",
"activityType": "tool-calling", "activityType": "tool-calling",
"agentId": "agent-reviewer",
"agentName": "Code Reviewer", "agentName": "Code Reviewer",
"toolName": "read_file" "toolName": "read_file"
} }
@@ -79,25 +81,25 @@ The Electron main process maps this to a `SessionEventRecord` with `kind: 'agent
### Renderer consumption ### Renderer consumption
`App.tsx` now subscribes to `onSessionEvent` and tracks the latest activity event for the selected session. `ChatPane.tsx` uses that state to show contextual messages like: `App.tsx` now subscribes to `onSessionEvent` and tracks live activity per agent for the selected session. `ChatPane.tsx` uses that state to show a status row for each agent while the run is active.
- "Code Reviewer is thinking…" - "Code Reviewer is thinking…"
- "Code Reviewer is using read_file…" - "Code Reviewer is using read_file…"
- "Handing off to Summarizer…" - "Handing off to Summarizer…"
The `ThinkingDots` component and activity indicator section in `ChatPane.tsx` are now wired to this data, with completed activity rendering as text-only status instead of an animated waiting state. The activity panel in `ChatPane.tsx` is now wired to this data, showing every agent in the pattern with a current status such as waiting, thinking, tool usage, handoff, or completed.
## Files involved ## Files involved
| Layer | File | Change | | Layer | File | Change |
|---|---|---| |---|---|---|
| Shared | `src/shared/domain/event.ts` | Add `'agent-activity'` to `SessionEventKind`, add optional `activityType` / `agentName` / `toolName` fields | | Shared | `src/shared/domain/event.ts` | Add `'agent-activity'` to `SessionEventKind`, add optional `activityType` / `agentId` / `agentName` / `toolName` fields |
| Shared | `src/shared/contracts/sidecar.ts` | Add `AgentActivityEvent` to the sidecar event union | | Shared | `src/shared/contracts/sidecar.ts` | Add `AgentActivityEvent` to the sidecar event union, including a stable optional `agentId` |
| Main | `src/main/sidecar/sidecarProcess.ts` | Parse `agent-activity` events from sidecar JSON output | | Main | `src/main/sidecar/sidecarProcess.ts` | Parse `agent-activity` events from sidecar JSON output |
| Main | `src/main/KopayaAppService.ts` | Map parsed activity events to `SessionEventRecord` and emit via `session-event` | | Main | `src/main/KopayaAppService.ts` | Map parsed activity events to `SessionEventRecord` and emit via `session-event` |
| Renderer | `src/renderer/App.tsx` | Subscribe to `onSessionEvent` and track live activity state per session | | Renderer | `src/renderer/App.tsx` | Subscribe to `onSessionEvent` and track live per-agent activity state per session |
| Renderer | `src/renderer/components/ChatPane.tsx` | Render contextual activity text in the existing activity indicator | | Renderer | `src/renderer/components/ChatPane.tsx` | Render an activity row for each agent while a session is running |
| Renderer | `src/renderer/lib/sessionActivity.ts` | Provide pure helpers for activity-state updates and display text | | Renderer | `src/renderer/lib/sessionActivity.ts` | Provide pure helpers for per-agent activity-state updates and display text |
| Sidecar | `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs` | Define `AgentActivityEventDto` | | Sidecar | `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs` | Define `AgentActivityEventDto`, including `agentId` |
| Sidecar | `sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs` | Emit `agent-activity` events during MAF turn execution when observable | | Sidecar | `sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs` | Emit `agent-activity` events during MAF turn execution when observable |
| Sidecar | `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs` | Forward activity events over the stdio protocol | | Sidecar | `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs` | Forward activity events over the stdio protocol |
@@ -109,6 +109,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto
{ {
public string SessionId { get; init; } = string.Empty; public string SessionId { get; init; } = string.Empty;
public string ActivityType { get; init; } = string.Empty; public string ActivityType { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; } public string? AgentName { get; init; }
public string? ToolName { get; init; } public string? ToolName { get; init; }
} }
@@ -23,6 +23,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions"); "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions");
private readonly PatternValidator _patternValidator; private readonly PatternValidator _patternValidator;
private readonly record struct AgentIdentity(string AgentId, string AgentName);
public CopilotWorkflowRunner(PatternValidator patternValidator) public CopilotWorkflowRunner(PatternValidator patternValidator)
{ {
_patternValidator = patternValidator; _patternValidator = patternValidator;
@@ -47,8 +49,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
List<StreamingSegment> segments = []; List<StreamingSegment> segments = [];
int fallbackMessageIndex = 0; int fallbackMessageIndex = 0;
List<ChatMessageDto> completedMessages = []; List<ChatMessageDto> completedMessages = [];
string? activeAgentId = null; AgentIdentity? activeAgent = null;
string? activeAgentName = null;
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false); await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
@@ -56,23 +57,23 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
{ {
if (evt is ExecutorInvokedEvent invoked if (evt is ExecutorInvokedEvent invoked
&& TryResolveKnownAgentName(command.Pattern, invoked.ExecutorId, out string invokedAgentName) && TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent)
&& !string.Equals(activeAgentId, invoked.ExecutorId, StringComparison.Ordinal)) && (!activeAgent.HasValue
|| !string.Equals(activeAgent.Value.AgentId, invokedAgent.AgentId, StringComparison.Ordinal)))
{ {
activeAgentId = invoked.ExecutorId; activeAgent = invokedAgent;
activeAgentName = invokedAgentName;
await onActivity(CreateActivityEvent( await onActivity(CreateActivityEvent(
command, command,
activityType: "thinking", activityType: "thinking",
agentName: invokedAgentName)).ConfigureAwait(false); agent: invokedAgent)).ConfigureAwait(false);
} }
else if (evt is RequestInfoEvent requestInfo) else if (evt is RequestInfoEvent requestInfo)
{ {
AgentActivityEventDto? activity = TryCreateActivityFromRequest( AgentActivityEventDto? activity = TryCreateActivityFromRequest(
command, command,
requestInfo, requestInfo,
activeAgentName); activeAgent);
if (activity is not null) if (activity is not null)
{ {
@@ -85,10 +86,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId); StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId);
segment.Content.Append(update.Update.Text); segment.Content.Append(update.Update.Text);
if (TryResolveKnownAgentName(command.Pattern, update.ExecutorId, out string updateAgentName)) if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent))
{ {
activeAgentId = update.ExecutorId; activeAgent = updateAgent;
activeAgentName = updateAgentName;
} }
await onDelta(new TurnDeltaEventDto await onDelta(new TurnDeltaEventDto
@@ -102,16 +102,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
else if (evt is ExecutorCompletedEvent completed else if (evt is ExecutorCompletedEvent completed
&& TryResolveKnownAgentName(command.Pattern, completed.ExecutorId, out string completedAgentName) && TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent)
&& string.Equals(activeAgentId, completed.ExecutorId, StringComparison.Ordinal)) && activeAgent.HasValue
&& string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{ {
await onActivity(CreateActivityEvent( await onActivity(CreateActivityEvent(
command, command,
activityType: "completed", activityType: "completed",
agentName: completedAgentName)).ConfigureAwait(false); agent: completedAgent)).ConfigureAwait(false);
activeAgentId = null; activeAgent = null;
activeAgentName = null;
} }
else if (evt is WorkflowOutputEvent outputEvent) else if (evt is WorkflowOutputEvent outputEvent)
{ {
@@ -127,7 +127,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static AgentActivityEventDto CreateActivityEvent( private static AgentActivityEventDto CreateActivityEvent(
RunTurnCommandDto command, RunTurnCommandDto command,
string activityType, string activityType,
string agentName, AgentIdentity agent,
string? toolName = null) string? toolName = null)
{ {
return new AgentActivityEventDto return new AgentActivityEventDto
@@ -136,7 +136,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
RequestId = command.RequestId, RequestId = command.RequestId,
SessionId = command.SessionId, SessionId = command.SessionId,
ActivityType = activityType, ActivityType = activityType,
AgentName = agentName, AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolName = toolName, ToolName = toolName,
}; };
} }
@@ -144,17 +145,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static AgentActivityEventDto? TryCreateActivityFromRequest( private static AgentActivityEventDto? TryCreateActivityFromRequest(
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
string? activeAgentName) AgentIdentity? activeAgent)
{ {
if (TryGetHandoffTargetName(command.Pattern, requestInfo, out string handoffAgentName)) if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent))
{ {
return CreateActivityEvent( return CreateActivityEvent(
command, command,
activityType: "handoff", activityType: "handoff",
agentName: handoffAgentName); agent: handoffAgent);
} }
if (string.IsNullOrWhiteSpace(activeAgentName) || !TryGetToolName(requestInfo, out string toolName)) if (!activeAgent.HasValue || !TryGetToolName(requestInfo, out string toolName))
{ {
return null; return null;
} }
@@ -162,27 +163,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return CreateActivityEvent( return CreateActivityEvent(
command, command,
activityType: "tool-calling", activityType: "tool-calling",
agentName: activeAgentName, agent: activeAgent.Value,
toolName: toolName); toolName: toolName);
} }
private static bool TryGetHandoffTargetName( private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
out string agentName) out AgentIdentity agent)
{ {
agentName = string.Empty; agent = default;
if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget)) if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget))
{ {
return false; return false;
} }
object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget); object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget);
agentName = ResolveAgentName( agent = ResolveAgentIdentity(
pattern, pattern,
GetStringProperty(target, "Id"), GetStringProperty(target, "Id"),
GetStringProperty(target, "Name")); GetStringProperty(target, "Name"));
return !string.IsNullOrWhiteSpace(agentName); return !string.IsNullOrWhiteSpace(agent.AgentName);
} }
private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName) private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName)
@@ -217,12 +218,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return false; return false;
} }
private static bool TryResolveKnownAgentName( private static bool TryResolveKnownAgentIdentity(
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
string? agentIdentifier, string? agentIdentifier,
out string agentName) out AgentIdentity agent)
{ {
agentName = string.Empty; agent = default;
if (string.IsNullOrWhiteSpace(agentIdentifier)) if (string.IsNullOrWhiteSpace(agentIdentifier))
{ {
return false; return false;
@@ -236,11 +237,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return false; return false;
} }
agentName = string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name; agent = new AgentIdentity(
match.Id,
string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name);
return true; return true;
} }
private static string ResolveAgentName( private static AgentIdentity ResolveAgentIdentity(
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
string? agentId, string? agentId,
string? agentName) string? agentName)
@@ -250,15 +253,21 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (match is not null) if (match is not null)
{ {
return string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name; return new AgentIdentity(
match.Id,
string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name);
} }
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
? agentId
: agentName ?? "agent";
if (!string.IsNullOrWhiteSpace(agentName)) if (!string.IsNullOrWhiteSpace(agentName))
{ {
return agentName; return new AgentIdentity(resolvedAgentId, agentName);
} }
return agentId ?? string.Empty; return new AgentIdentity(resolvedAgentId, resolvedAgentId);
} }
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate) private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
@@ -103,6 +103,7 @@ public sealed class SidecarProtocolHostTests
RequestId = command.RequestId, RequestId = command.RequestId,
SessionId = command.SessionId, SessionId = command.SessionId,
ActivityType = "thinking", ActivityType = "thinking",
AgentId = "agent-1",
AgentName = "Primary", AgentName = "Primary",
}); });
@@ -122,6 +123,7 @@ public sealed class SidecarProtocolHostTests
RequestId = command.RequestId, RequestId = command.RequestId,
SessionId = command.SessionId, SessionId = command.SessionId,
ActivityType = "tool-calling", ActivityType = "tool-calling",
AgentId = "agent-1",
AgentName = "Primary", AgentName = "Primary",
ToolName = "read_file", ToolName = "read_file",
}); });
@@ -179,6 +181,7 @@ public sealed class SidecarProtocolHostTests
Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString()); Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString());
Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString()); Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString());
Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString()); Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString());
Assert.Equal("agent-1", thinkingEvent.GetProperty("agentId").GetString());
Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString()); Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString());
}, },
deltaEvent => deltaEvent =>
@@ -190,6 +193,7 @@ public sealed class SidecarProtocolHostTests
{ {
Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString()); Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString());
Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString()); Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString());
Assert.Equal("agent-1", toolEvent.GetProperty("agentId").GetString());
Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString()); Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString());
}, },
completionEvent => completionEvent =>
+1
View File
@@ -310,6 +310,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
kind: 'agent-activity', kind: 'agent-activity',
occurredAt: nowIso(), occurredAt: nowIso(),
activityType: event.activityType, activityType: event.activityType,
agentId: event.agentId,
agentName: event.agentName, agentName: event.agentName,
toolName: event.toolName, toolName: event.toolName,
}); });
+34 -18
View File
@@ -1,9 +1,11 @@
import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react'; import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
import { import {
formatSessionActivityLabel, buildAgentActivityRows,
shouldAnimateSessionActivity, formatAgentActivityLabel,
isAgentActivityActive,
isAgentActivityCompleted,
type SessionActivityState, type SessionActivityState,
} from '@renderer/lib/sessionActivity'; } from '@renderer/lib/sessionActivity';
import type { PatternDefinition } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern';
@@ -34,10 +36,10 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isBusy = session.status === 'running'; const isBusy = session.status === 'running';
const hasPendingMessage = session.messages.some((m) => m.pending); const activityRows = useMemo(
const isThinking = isBusy && !hasPendingMessage; () => buildAgentActivityRows(activity, pattern.agents, isBusy),
const activityLabel = formatSessionActivityLabel(activity, pattern.agents[0]?.name ?? 'Agent'); [activity, isBusy, pattern.agents],
const showActivityAnimation = shouldAnimateSessionActivity(activity); );
useEffect(() => { useEffect(() => {
transcriptRef.current?.scrollTo({ transcriptRef.current?.scrollTo({
@@ -136,17 +138,31 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
})} })}
</div> </div>
{/* Activity indicator — shown while the agent is thinking (before streaming starts) */} {isBusy && activityRows.length > 0 && (
{isThinking && ( <div className="mb-4 rounded-xl border border-zinc-800 bg-zinc-900/60 px-4 py-3">
<div className="py-3"> <div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-600">
<div className="flex gap-3"> Agent activity
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-zinc-800 text-zinc-400"> </div>
<Bot className="size-3.5" /> <div className="space-y-2.5">
</div> {activityRows.map((row) => (
<div className="min-w-0 flex-1"> <div className="flex items-start gap-3" key={row.key}>
<div className="mb-1.5 text-[12px] font-medium text-zinc-500">{activityLabel}</div> <span
{showActivityAnimation && <ThinkingDots />} className={`mt-1.5 size-2 shrink-0 rounded-full ${
</div> isAgentActivityActive(row.activity)
? 'animate-pulse bg-blue-400'
: isAgentActivityCompleted(row.activity)
? 'bg-emerald-400'
: 'bg-zinc-700'
}`}
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-medium text-zinc-300">{row.agentName}</div>
<div className="text-[12px] text-zinc-500">
{formatAgentActivityLabel(row.activity)}
</div>
</div>
</div>
))}
</div> </div>
</div> </div>
)} )}
+83 -18
View File
@@ -1,26 +1,42 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import type { SessionEventRecord } from '@shared/domain/event'; import type { SessionEventRecord } from '@shared/domain/event';
export interface SessionActivityState { export interface AgentActivityState {
sessionId: string; agentId: string;
agentName: string;
activityType?: SessionEventRecord['activityType']; activityType?: SessionEventRecord['activityType'];
agentName?: string;
toolName?: string; toolName?: string;
} }
export type SessionActivityState = Record<string, AgentActivityState>;
export type SessionActivityMap = Record<string, SessionActivityState | undefined>; export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
export interface AgentActivityRow {
key: string;
agentName: string;
activity?: AgentActivityState;
}
export function applySessionEventActivity( export function applySessionEventActivity(
current: SessionActivityMap, current: SessionActivityMap,
event: SessionEventRecord, event: SessionEventRecord,
): SessionActivityMap { ): SessionActivityMap {
if (event.kind === 'agent-activity') { if (event.kind === 'agent-activity') {
const agentKey = resolveAgentKey(event);
if (!agentKey) {
return current;
}
return { return {
...current, ...current,
[event.sessionId]: { [event.sessionId]: {
sessionId: event.sessionId, ...(current[event.sessionId] ?? {}),
activityType: event.activityType, [agentKey]: {
agentName: event.agentName, agentId: event.agentId ?? agentKey,
toolName: event.toolName, agentName: event.agentName?.trim() || event.agentId?.trim() || agentKey,
activityType: event.activityType,
toolName: event.toolName,
},
}, },
}; };
} }
@@ -58,27 +74,72 @@ export function pruneSessionActivities(
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current; return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
} }
export function formatSessionActivityLabel( export function buildAgentActivityRows(
activity: SessionActivityState | undefined, current: SessionActivityState | undefined,
fallbackAgentName = 'Agent', agents: PatternDefinition['agents'],
): string { isBusy: boolean,
const agentName = activity?.agentName?.trim() || fallbackAgentName; ): AgentActivityRow[] {
const hasReportedActivity = !!current && Object.keys(current).length > 0;
return agents.map((agent, index) => {
const activity = current?.[agent.id] ?? current?.[agent.name];
if (activity) {
return {
key: agent.id,
agentName: agent.name,
activity,
};
}
if (!hasReportedActivity && isBusy && index === 0) {
return {
key: agent.id,
agentName: agent.name,
activity: {
agentId: agent.id,
agentName: agent.name,
activityType: 'thinking',
},
};
}
return {
key: agent.id,
agentName: agent.name,
};
});
}
export function formatAgentActivityLabel(activity: AgentActivityState | undefined): string {
if (!activity) {
return 'Waiting…';
}
switch (activity?.activityType) { switch (activity?.activityType) {
case 'tool-calling': case 'tool-calling':
return `${agentName} is using ${activity.toolName?.trim() || 'a tool'}`; return `Using ${activity.toolName?.trim() || 'a tool'}`;
case 'handoff': case 'handoff':
return `Handing off to ${agentName}`; return 'Handling handoff…';
case 'completed': case 'completed':
return `${agentName} completed their turn.`; return 'Completed';
case 'thinking': case 'thinking':
return 'Thinking…';
default: default:
return `${agentName} is thinking…`; return 'Waiting…';
} }
} }
export function shouldAnimateSessionActivity(activity: SessionActivityState | undefined): boolean { export function isAgentActivityActive(activity: AgentActivityState | undefined): boolean {
return activity?.activityType !== 'completed'; return (
activity?.activityType === 'thinking'
|| activity?.activityType === 'tool-calling'
|| activity?.activityType === 'handoff'
);
}
export function isAgentActivityCompleted(activity: AgentActivityState | undefined): boolean {
return activity?.activityType === 'completed';
} }
function removeSessionActivity( function removeSessionActivity(
@@ -93,3 +154,7 @@ function removeSessionActivity(
delete next[sessionId]; delete next[sessionId];
return next; return next;
} }
function resolveAgentKey(event: SessionEventRecord): string | undefined {
return event.agentId?.trim() || event.agentName?.trim();
}
+1
View File
@@ -68,6 +68,7 @@ export interface AgentActivityEvent {
requestId: string; requestId: string;
sessionId: string; sessionId: string;
activityType: AgentActivityType; activityType: AgentActivityType;
agentId?: string;
agentName?: string; agentName?: string;
toolName?: string; toolName?: string;
} }
+1
View File
@@ -16,6 +16,7 @@ export interface SessionEventRecord {
authorName?: string; authorName?: string;
contentDelta?: string; contentDelta?: string;
activityType?: SessionActivityType; activityType?: SessionActivityType;
agentId?: string;
agentName?: string; agentName?: string;
toolName?: string; toolName?: string;
error?: string; error?: string;
+167 -52
View File
@@ -2,30 +2,83 @@ import { describe, expect, test } from 'bun:test';
import { import {
applySessionEventActivity, applySessionEventActivity,
formatSessionActivityLabel, buildAgentActivityRows,
formatAgentActivityLabel,
isAgentActivityActive,
isAgentActivityCompleted,
pruneSessionActivities, pruneSessionActivities,
shouldAnimateSessionActivity,
type SessionActivityMap, type SessionActivityMap,
} from '@renderer/lib/sessionActivity'; } from '@renderer/lib/sessionActivity';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { SessionEventRecord } from '@shared/domain/event'; import type { SessionEventRecord } from '@shared/domain/event';
describe('session activity helpers', () => { describe('session activity helpers', () => {
test('stores the latest agent activity by session', () => { const agents: PatternDefinition['agents'] = [
const event: SessionEventRecord = { {
id: 'architect',
name: 'Architect',
description: 'Designs the system.',
instructions: 'Think about architecture.',
model: 'gpt-5.4',
reasoningEffort: 'high',
},
{
id: 'reviewer',
name: 'Reviewer',
description: 'Reviews the solution.',
instructions: 'Review the work.',
model: 'gpt-5.4',
reasoningEffort: 'medium',
},
];
test('stores activity per session and per agent', () => {
const architectEvent: SessionEventRecord = {
sessionId: 'session-1', sessionId: 'session-1',
kind: 'agent-activity', kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z', occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'thinking',
agentId: 'architect',
agentName: 'Architect',
};
const reviewerEvent: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:01.000Z',
activityType: 'tool-calling', activityType: 'tool-calling',
agentName: 'Code Reviewer', agentId: 'reviewer',
agentName: 'Reviewer',
toolName: 'read_file', toolName: 'read_file',
}; };
expect(applySessionEventActivity({}, event)).toEqual({ expect(applySessionEventActivity({}, architectEvent)).toEqual({
'session-1': { 'session-1': {
sessionId: 'session-1', architect: {
activityType: 'tool-calling', agentId: 'architect',
agentName: 'Code Reviewer', agentName: 'Architect',
toolName: 'read_file', activityType: 'thinking',
},
},
});
expect(
applySessionEventActivity(
applySessionEventActivity({}, architectEvent),
reviewerEvent,
),
).toEqual({
'session-1': {
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking',
},
reviewer: {
agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'tool-calling',
toolName: 'read_file',
},
}, },
}); });
}); });
@@ -33,14 +86,18 @@ describe('session activity helpers', () => {
test('clears stale activity when a session restarts or finishes', () => { test('clears stale activity when a session restarts or finishes', () => {
const current: SessionActivityMap = { const current: SessionActivityMap = {
'session-1': { 'session-1': {
sessionId: 'session-1', architect: {
activityType: 'thinking', agentId: 'architect',
agentName: 'Primary', agentName: 'Architect',
activityType: 'thinking',
},
}, },
'session-2': { 'session-2': {
sessionId: 'session-2', reviewer: {
activityType: 'handoff', agentId: 'reviewer',
agentName: 'Reviewer', agentName: 'Reviewer',
activityType: 'handoff',
},
}, },
}; };
@@ -67,63 +124,121 @@ describe('session activity helpers', () => {
}); });
}); });
test('formats contextual activity labels and animation state', () => { test('builds rows for all agents with sensible defaults', () => {
expect(formatSessionActivityLabel(undefined, 'Primary')).toBe('Primary is thinking…'); expect(buildAgentActivityRows(undefined, agents, true)).toEqual([
{
key: 'architect',
agentName: 'Architect',
activity: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking',
},
},
{
key: 'reviewer',
agentName: 'Reviewer',
},
]);
expect( expect(
formatSessionActivityLabel( buildAgentActivityRows(
{ {
sessionId: 'session-1', architect: {
activityType: 'tool-calling', agentId: 'architect',
agentName: 'Architect',
activityType: 'completed',
},
reviewer: {
agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'tool-calling',
toolName: 'read_file',
},
},
agents,
true,
),
).toEqual([
{
key: 'architect',
agentName: 'Architect',
activity: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'completed',
},
},
{
key: 'reviewer',
agentName: 'Reviewer',
activity: {
agentId: 'reviewer',
agentName: 'Reviewer', agentName: 'Reviewer',
activityType: 'tool-calling',
toolName: 'read_file', toolName: 'read_file',
}, },
'Primary', },
), ]);
).toBe('Reviewer is using read_file…'); });
test('formats contextual activity labels and state flags', () => {
expect(formatAgentActivityLabel(undefined)).toBe('Waiting…');
expect( expect(
formatSessionActivityLabel( formatAgentActivityLabel({
{ agentId: 'reviewer',
sessionId: 'session-1', agentName: 'Reviewer',
activityType: 'handoff', activityType: 'tool-calling',
agentName: 'Summarizer', toolName: 'read_file',
}, }),
'Primary', ).toBe('Using read_file…');
),
).toBe('Handing off to Summarizer…');
expect( expect(
formatSessionActivityLabel( formatAgentActivityLabel({
{ agentId: 'reviewer',
sessionId: 'session-1', agentName: 'Reviewer',
activityType: 'completed', activityType: 'handoff',
agentName: 'Reviewer', }),
}, ).toBe('Handling handoff…');
'Primary',
),
).toBe('Reviewer completed their turn.');
expect( expect(
shouldAnimateSessionActivity({ formatAgentActivityLabel({
sessionId: 'session-1', agentId: 'reviewer',
agentName: 'Reviewer',
activityType: 'completed',
}),
).toBe('Completed');
expect(
isAgentActivityActive({
agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking', activityType: 'thinking',
}), }),
).toBe(true); ).toBe(true);
expect( expect(
shouldAnimateSessionActivity({ isAgentActivityCompleted({
sessionId: 'session-1', agentId: 'architect',
agentName: 'Architect',
activityType: 'completed', activityType: 'completed',
}), }),
).toBe(false); ).toBe(true);
expect(isAgentActivityCompleted(undefined)).toBe(false);
}); });
test('prunes activity state for sessions that no longer exist', () => { test('prunes activity state for sessions that no longer exist', () => {
const current: SessionActivityMap = { const current: SessionActivityMap = {
'session-1': { 'session-1': {
sessionId: 'session-1', architect: {
activityType: 'thinking', agentId: 'architect',
agentName: 'Architect',
activityType: 'thinking',
},
}, },
'session-2': { 'session-2': {
sessionId: 'session-2', reviewer: {
activityType: 'tool-calling', agentId: 'reviewer',
toolName: 'read_file', agentName: 'Reviewer',
activityType: 'tool-calling',
toolName: 'read_file',
},
}, },
}; };