diff --git a/BACKEND_UI_CHANGES.md b/BACKEND_UI_CHANGES.md index c921e18..c53450d 100644 --- a/BACKEND_UI_CHANGES.md +++ b/BACKEND_UI_CHANGES.md @@ -42,6 +42,7 @@ export interface SessionEventRecord { // New fields for 'agent-activity' events activityType?: 'thinking' | 'tool-calling' | 'handoff' | 'completed'; + agentId?: string; agentName?: string; toolName?: string; } @@ -70,6 +71,7 @@ In the sidecar's turn-execution pipeline, emit a new JSON event type alongside t "requestId": "…", "sessionId": "…", "activityType": "tool-calling", + "agentId": "agent-reviewer", "agentName": "Code Reviewer", "toolName": "read_file" } @@ -79,25 +81,25 @@ The Electron main process maps this to a `SessionEventRecord` with `kind: 'agent ### 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 using read_file…" - "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 | Layer | File | Change | |---|---|---| -| Shared | `src/shared/domain/event.ts` | Add `'agent-activity'` to `SessionEventKind`, add optional `activityType` / `agentName` / `toolName` fields | -| Shared | `src/shared/contracts/sidecar.ts` | Add `AgentActivityEvent` to the sidecar event union | +| 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, including a stable optional `agentId` | | 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` | -| Renderer | `src/renderer/App.tsx` | Subscribe to `onSessionEvent` and track live activity state per session | -| Renderer | `src/renderer/components/ChatPane.tsx` | Render contextual activity text in the existing activity indicator | -| Renderer | `src/renderer/lib/sessionActivity.ts` | Provide pure helpers for activity-state updates and display text | -| Sidecar | `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs` | Define `AgentActivityEventDto` | +| Renderer | `src/renderer/App.tsx` | Subscribe to `onSessionEvent` and track live per-agent activity state per session | +| 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 per-agent activity-state updates and display text | +| 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/SidecarProtocolHost.cs` | Forward activity events over the stdio protocol | diff --git a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs index 6ffe921..53ad860 100644 --- a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs @@ -109,6 +109,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; public string ActivityType { get; init; } = string.Empty; + public string? AgentId { get; init; } public string? AgentName { get; init; } public string? ToolName { get; init; } } diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index 5a4dca8..caa9984 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -23,6 +23,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions"); private readonly PatternValidator _patternValidator; + private readonly record struct AgentIdentity(string AgentId, string AgentName); + public CopilotWorkflowRunner(PatternValidator patternValidator) { _patternValidator = patternValidator; @@ -47,8 +49,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner List segments = []; int fallbackMessageIndex = 0; List completedMessages = []; - string? activeAgentId = null; - string? activeAgentName = null; + AgentIdentity? activeAgent = null; await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).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)) { if (evt is ExecutorInvokedEvent invoked - && TryResolveKnownAgentName(command.Pattern, invoked.ExecutorId, out string invokedAgentName) - && !string.Equals(activeAgentId, invoked.ExecutorId, StringComparison.Ordinal)) + && TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent) + && (!activeAgent.HasValue + || !string.Equals(activeAgent.Value.AgentId, invokedAgent.AgentId, StringComparison.Ordinal))) { - activeAgentId = invoked.ExecutorId; - activeAgentName = invokedAgentName; + activeAgent = invokedAgent; await onActivity(CreateActivityEvent( command, activityType: "thinking", - agentName: invokedAgentName)).ConfigureAwait(false); + agent: invokedAgent)).ConfigureAwait(false); } else if (evt is RequestInfoEvent requestInfo) { AgentActivityEventDto? activity = TryCreateActivityFromRequest( command, requestInfo, - activeAgentName); + activeAgent); if (activity is not null) { @@ -85,10 +86,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId); 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; - activeAgentName = updateAgentName; + activeAgent = updateAgent; } await onDelta(new TurnDeltaEventDto @@ -102,16 +102,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner }).ConfigureAwait(false); } else if (evt is ExecutorCompletedEvent completed - && TryResolveKnownAgentName(command.Pattern, completed.ExecutorId, out string completedAgentName) - && string.Equals(activeAgentId, completed.ExecutorId, StringComparison.Ordinal)) + && TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent) + && activeAgent.HasValue + && string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal)) { await onActivity(CreateActivityEvent( command, activityType: "completed", - agentName: completedAgentName)).ConfigureAwait(false); + agent: completedAgent)).ConfigureAwait(false); - activeAgentId = null; - activeAgentName = null; + activeAgent = null; } else if (evt is WorkflowOutputEvent outputEvent) { @@ -127,7 +127,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner private static AgentActivityEventDto CreateActivityEvent( RunTurnCommandDto command, string activityType, - string agentName, + AgentIdentity agent, string? toolName = null) { return new AgentActivityEventDto @@ -136,7 +136,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner RequestId = command.RequestId, SessionId = command.SessionId, ActivityType = activityType, - AgentName = agentName, + AgentId = agent.AgentId, + AgentName = agent.AgentName, ToolName = toolName, }; } @@ -144,17 +145,17 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner private static AgentActivityEventDto? TryCreateActivityFromRequest( RunTurnCommandDto command, RequestInfoEvent requestInfo, - string? activeAgentName) + AgentIdentity? activeAgent) { - if (TryGetHandoffTargetName(command.Pattern, requestInfo, out string handoffAgentName)) + if (TryGetHandoffTarget(command.Pattern, requestInfo, out AgentIdentity handoffAgent)) { return CreateActivityEvent( command, 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; } @@ -162,27 +163,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return CreateActivityEvent( command, activityType: "tool-calling", - agentName: activeAgentName, + agent: activeAgent.Value, toolName: toolName); } - private static bool TryGetHandoffTargetName( + private static bool TryGetHandoffTarget( PatternDefinitionDto pattern, RequestInfoEvent requestInfo, - out string agentName) + out AgentIdentity agent) { - agentName = string.Empty; + agent = default; if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget)) { return false; } object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget); - agentName = ResolveAgentName( + agent = ResolveAgentIdentity( pattern, GetStringProperty(target, "Id"), GetStringProperty(target, "Name")); - return !string.IsNullOrWhiteSpace(agentName); + return !string.IsNullOrWhiteSpace(agent.AgentName); } private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName) @@ -217,12 +218,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return false; } - private static bool TryResolveKnownAgentName( + private static bool TryResolveKnownAgentIdentity( PatternDefinitionDto pattern, string? agentIdentifier, - out string agentName) + out AgentIdentity agent) { - agentName = string.Empty; + agent = default; if (string.IsNullOrWhiteSpace(agentIdentifier)) { return false; @@ -236,11 +237,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner 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; } - private static string ResolveAgentName( + private static AgentIdentity ResolveAgentIdentity( PatternDefinitionDto pattern, string? agentId, string? agentName) @@ -250,15 +253,21 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner 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)) { - return agentName; + return new AgentIdentity(resolvedAgentId, agentName); } - return agentId ?? string.Empty; + return new AgentIdentity(resolvedAgentId, resolvedAgentId); } private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate) diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs index af674bc..eff26ed 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -103,6 +103,7 @@ public sealed class SidecarProtocolHostTests RequestId = command.RequestId, SessionId = command.SessionId, ActivityType = "thinking", + AgentId = "agent-1", AgentName = "Primary", }); @@ -122,6 +123,7 @@ public sealed class SidecarProtocolHostTests RequestId = command.RequestId, SessionId = command.SessionId, ActivityType = "tool-calling", + AgentId = "agent-1", AgentName = "Primary", ToolName = "read_file", }); @@ -179,6 +181,7 @@ public sealed class SidecarProtocolHostTests Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString()); Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString()); Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString()); + Assert.Equal("agent-1", thinkingEvent.GetProperty("agentId").GetString()); Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString()); }, deltaEvent => @@ -190,6 +193,7 @@ public sealed class SidecarProtocolHostTests { Assert.Equal("agent-activity", toolEvent.GetProperty("type").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()); }, completionEvent => diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index 93a7c3d..cba9026 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -310,6 +310,7 @@ export class KopayaAppService extends EventEmitter { kind: 'agent-activity', occurredAt: nowIso(), activityType: event.activityType, + agentId: event.agentId, agentName: event.agentName, toolName: event.toolName, }); diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 24db3b0..4abc997 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -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 { - formatSessionActivityLabel, - shouldAnimateSessionActivity, + buildAgentActivityRows, + formatAgentActivityLabel, + isAgentActivityActive, + isAgentActivityCompleted, type SessionActivityState, } from '@renderer/lib/sessionActivity'; import type { PatternDefinition } from '@shared/domain/pattern'; @@ -34,10 +36,10 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa const textareaRef = useRef(null); const isBusy = session.status === 'running'; - const hasPendingMessage = session.messages.some((m) => m.pending); - const isThinking = isBusy && !hasPendingMessage; - const activityLabel = formatSessionActivityLabel(activity, pattern.agents[0]?.name ?? 'Agent'); - const showActivityAnimation = shouldAnimateSessionActivity(activity); + const activityRows = useMemo( + () => buildAgentActivityRows(activity, pattern.agents, isBusy), + [activity, isBusy, pattern.agents], + ); useEffect(() => { transcriptRef.current?.scrollTo({ @@ -136,17 +138,31 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa })} - {/* Activity indicator — shown while the agent is thinking (before streaming starts) */} - {isThinking && ( -
-
-
- -
-
-
{activityLabel}
- {showActivityAnimation && } -
+ {isBusy && activityRows.length > 0 && ( +
+
+ Agent activity +
+
+ {activityRows.map((row) => ( +
+ +
+
{row.agentName}
+
+ {formatAgentActivityLabel(row.activity)} +
+
+
+ ))}
)} diff --git a/src/renderer/lib/sessionActivity.ts b/src/renderer/lib/sessionActivity.ts index 1ddd5aa..9b856cf 100644 --- a/src/renderer/lib/sessionActivity.ts +++ b/src/renderer/lib/sessionActivity.ts @@ -1,26 +1,42 @@ +import type { PatternDefinition } from '@shared/domain/pattern'; import type { SessionEventRecord } from '@shared/domain/event'; -export interface SessionActivityState { - sessionId: string; +export interface AgentActivityState { + agentId: string; + agentName: string; activityType?: SessionEventRecord['activityType']; - agentName?: string; toolName?: string; } +export type SessionActivityState = Record; export type SessionActivityMap = Record; +export interface AgentActivityRow { + key: string; + agentName: string; + activity?: AgentActivityState; +} + export function applySessionEventActivity( current: SessionActivityMap, event: SessionEventRecord, ): SessionActivityMap { if (event.kind === 'agent-activity') { + const agentKey = resolveAgentKey(event); + if (!agentKey) { + return current; + } + return { ...current, [event.sessionId]: { - sessionId: event.sessionId, - activityType: event.activityType, - agentName: event.agentName, - toolName: event.toolName, + ...(current[event.sessionId] ?? {}), + [agentKey]: { + agentId: event.agentId ?? agentKey, + 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; } -export function formatSessionActivityLabel( - activity: SessionActivityState | undefined, - fallbackAgentName = 'Agent', -): string { - const agentName = activity?.agentName?.trim() || fallbackAgentName; +export function buildAgentActivityRows( + current: SessionActivityState | undefined, + agents: PatternDefinition['agents'], + isBusy: boolean, +): 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) { case 'tool-calling': - return `${agentName} is using ${activity.toolName?.trim() || 'a tool'}…`; + return `Using ${activity.toolName?.trim() || 'a tool'}…`; case 'handoff': - return `Handing off to ${agentName}…`; + return 'Handling handoff…'; case 'completed': - return `${agentName} completed their turn.`; + return 'Completed'; case 'thinking': + return 'Thinking…'; default: - return `${agentName} is thinking…`; + return 'Waiting…'; } } -export function shouldAnimateSessionActivity(activity: SessionActivityState | undefined): boolean { - return activity?.activityType !== 'completed'; +export function isAgentActivityActive(activity: AgentActivityState | undefined): boolean { + return ( + activity?.activityType === 'thinking' + || activity?.activityType === 'tool-calling' + || activity?.activityType === 'handoff' + ); +} + +export function isAgentActivityCompleted(activity: AgentActivityState | undefined): boolean { + return activity?.activityType === 'completed'; } function removeSessionActivity( @@ -93,3 +154,7 @@ function removeSessionActivity( delete next[sessionId]; return next; } + +function resolveAgentKey(event: SessionEventRecord): string | undefined { + return event.agentId?.trim() || event.agentName?.trim(); +} diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 5ec5f9f..ff7f270 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -68,6 +68,7 @@ export interface AgentActivityEvent { requestId: string; sessionId: string; activityType: AgentActivityType; + agentId?: string; agentName?: string; toolName?: string; } diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 85b13ac..779827f 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -16,6 +16,7 @@ export interface SessionEventRecord { authorName?: string; contentDelta?: string; activityType?: SessionActivityType; + agentId?: string; agentName?: string; toolName?: string; error?: string; diff --git a/tests/renderer/sessionActivity.test.ts b/tests/renderer/sessionActivity.test.ts index ef0d4a5..84479d9 100644 --- a/tests/renderer/sessionActivity.test.ts +++ b/tests/renderer/sessionActivity.test.ts @@ -2,30 +2,83 @@ import { describe, expect, test } from 'bun:test'; import { applySessionEventActivity, - formatSessionActivityLabel, + buildAgentActivityRows, + formatAgentActivityLabel, + isAgentActivityActive, + isAgentActivityCompleted, pruneSessionActivities, - shouldAnimateSessionActivity, type SessionActivityMap, } from '@renderer/lib/sessionActivity'; +import type { PatternDefinition } from '@shared/domain/pattern'; import type { SessionEventRecord } from '@shared/domain/event'; describe('session activity helpers', () => { - test('stores the latest agent activity by session', () => { - const event: SessionEventRecord = { + const agents: PatternDefinition['agents'] = [ + { + 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', kind: 'agent-activity', 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', - agentName: 'Code Reviewer', + agentId: 'reviewer', + agentName: 'Reviewer', toolName: 'read_file', }; - expect(applySessionEventActivity({}, event)).toEqual({ + expect(applySessionEventActivity({}, architectEvent)).toEqual({ 'session-1': { - sessionId: 'session-1', - activityType: 'tool-calling', - agentName: 'Code Reviewer', - toolName: 'read_file', + architect: { + agentId: 'architect', + agentName: 'Architect', + 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', () => { const current: SessionActivityMap = { 'session-1': { - sessionId: 'session-1', - activityType: 'thinking', - agentName: 'Primary', + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'thinking', + }, }, 'session-2': { - sessionId: 'session-2', - activityType: 'handoff', - agentName: 'Reviewer', + reviewer: { + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'handoff', + }, }, }; @@ -67,63 +124,121 @@ describe('session activity helpers', () => { }); }); - test('formats contextual activity labels and animation state', () => { - expect(formatSessionActivityLabel(undefined, 'Primary')).toBe('Primary is thinking…'); + test('builds rows for all agents with sensible defaults', () => { + expect(buildAgentActivityRows(undefined, agents, true)).toEqual([ + { + key: 'architect', + agentName: 'Architect', + activity: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'thinking', + }, + }, + { + key: 'reviewer', + agentName: 'Reviewer', + }, + ]); + expect( - formatSessionActivityLabel( + buildAgentActivityRows( { - sessionId: 'session-1', - activityType: 'tool-calling', + architect: { + 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', + activityType: 'tool-calling', toolName: 'read_file', }, - 'Primary', - ), - ).toBe('Reviewer is using read_file…'); + }, + ]); + }); + + test('formats contextual activity labels and state flags', () => { + expect(formatAgentActivityLabel(undefined)).toBe('Waiting…'); expect( - formatSessionActivityLabel( - { - sessionId: 'session-1', - activityType: 'handoff', - agentName: 'Summarizer', - }, - 'Primary', - ), - ).toBe('Handing off to Summarizer…'); + formatAgentActivityLabel({ + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'tool-calling', + toolName: 'read_file', + }), + ).toBe('Using read_file…'); expect( - formatSessionActivityLabel( - { - sessionId: 'session-1', - activityType: 'completed', - agentName: 'Reviewer', - }, - 'Primary', - ), - ).toBe('Reviewer completed their turn.'); + formatAgentActivityLabel({ + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'handoff', + }), + ).toBe('Handling handoff…'); expect( - shouldAnimateSessionActivity({ - sessionId: 'session-1', + formatAgentActivityLabel({ + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'completed', + }), + ).toBe('Completed'); + expect( + isAgentActivityActive({ + agentId: 'architect', + agentName: 'Architect', activityType: 'thinking', }), ).toBe(true); expect( - shouldAnimateSessionActivity({ - sessionId: 'session-1', + isAgentActivityCompleted({ + agentId: 'architect', + agentName: 'Architect', activityType: 'completed', }), - ).toBe(false); + ).toBe(true); + expect(isAgentActivityCompleted(undefined)).toBe(false); }); test('prunes activity state for sessions that no longer exist', () => { const current: SessionActivityMap = { 'session-1': { - sessionId: 'session-1', - activityType: 'thinking', + architect: { + agentId: 'architect', + agentName: 'Architect', + activityType: 'thinking', + }, }, 'session-2': { - sessionId: 'session-2', - activityType: 'tool-calling', - toolName: 'read_file', + reviewer: { + agentId: 'reviewer', + agentName: 'Reviewer', + activityType: 'tool-calling', + toolName: 'read_file', + }, }, };