diff --git a/BACKEND_UI_CHANGES.md b/BACKEND_UI_CHANGES.md index 37631ef..c921e18 100644 --- a/BACKEND_UI_CHANGES.md +++ b/BACKEND_UI_CHANGES.md @@ -1,14 +1,16 @@ # Backend UI Changes -This document describes changes to the .NET sidecar / backend protocol that would enable richer agent-activity reporting in the chat UI. These are **not yet implemented** — the current UI infers activity state from existing events. A separate agent should implement these changes. +This document describes the implemented .NET sidecar / backend protocol changes that enable richer agent-activity reporting in the chat UI. + +The repository now emits and consumes `agent-activity` events end to end. Because the current MAF workflow stream does not expose every lifecycle detail uniformly, activity events are emitted only when they can be detected reliably from the available workflow events. ## Context The chat UI now shows a "Thinking…" indicator while the agent processes a request (before streaming starts) and a blinking cursor while the response streams in. These states are inferred from the existing `status` and `message-delta` session events. -To display more granular activity (e.g. "Using tool X…", "Agent Y is thinking…", "Handing off to Agent Z…"), the sidecar protocol needs a new event kind. +To display more granular activity (e.g. "Using tool X…", "Agent Y is thinking…", "Handing off to Agent Z…"), the sidecar protocol uses a new event kind. -## Proposed protocol addition +## Protocol addition ### New event kind: `agent-activity` @@ -47,7 +49,7 @@ export interface SessionEventRecord { ### Sidecar event mapping -The .NET sidecar should emit `agent-activity` events at these points: +The .NET sidecar emits `agent-activity` events when the workflow stream exposes these points reliably: | MAF lifecycle point | `activityType` | `agentName` | `toolName` | |---|---|---|---| @@ -56,9 +58,11 @@ The .NET sidecar should emit `agent-activity` events at these points: | Handoff orchestration transfers control | `handoff` | target agent name | — | | Agent finishes its contribution | `completed` | agent name | — | +In practice, `thinking` and `completed` are driven by executor lifecycle events, while `tool-calling` and `handoff` are emitted when request-info payloads expose recognizable tool-call or handoff targets. + ### .NET sidecar changes -In the sidecar's turn-execution pipeline, emit a new JSON event type alongside the existing `turn-delta` and `turn-complete`: +In the sidecar's turn-execution pipeline, emit a new JSON event type alongside the existing `turn-delta` and `turn-complete` events: ```json { @@ -71,23 +75,29 @@ In the sidecar's turn-execution pipeline, emit a new JSON event type alongside t } ``` -The Electron main process (`KopayaAppService`) should map this to a `SessionEventRecord` with `kind: 'agent-activity'` and forward it to the renderer via the existing `sessions:event` channel. +The Electron main process maps this to a `SessionEventRecord` with `kind: 'agent-activity'` and forwards it to the renderer via the existing `sessions:event` channel. -### Renderer consumption (already prepared) +### Renderer consumption -Once these events are available, the `ChatPane` activity indicator can be enhanced to show contextual messages like: +`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: - "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 designed to be extended with this data. +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. -## Files to change +## Files involved | Layer | File | Change | |---|---|---| | Shared | `src/shared/domain/event.ts` | Add `'agent-activity'` to `SessionEventKind`, add optional `activityType` / `agentName` / `toolName` fields | -| Main | `src/main/sidecar/sidecar.ts` | Parse `agent-activity` events from sidecar JSON output | +| Shared | `src/shared/contracts/sidecar.ts` | Add `AgentActivityEvent` to the sidecar event union | +| 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` | -| Sidecar | `sidecar/src/Kopaya.AgentHost/…` | Emit `agent-activity` JSON events during MAF turn execution | +| 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` | +| 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 5b7c1f3..6ffe921 100644 --- a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs @@ -105,6 +105,14 @@ public sealed class TurnCompleteEventDto : SidecarEventDto public IReadOnlyList Messages { get; init; } = []; } +public sealed class AgentActivityEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string ActivityType { get; init; } = string.Empty; + public string? AgentName { get; init; } + public string? ToolName { get; init; } +} + public sealed class CommandErrorEventDto : SidecarEventDto { public string Message { get; init; } = string.Empty; diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index fc8ddb1..5a4dca8 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -9,8 +9,18 @@ using Microsoft.Extensions.AI; namespace Kopaya.AgentHost.Services; -public sealed class CopilotWorkflowRunner +public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { + private static readonly Type? HandoffTargetType = LoadType( + "Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows"); + private static readonly Type? FunctionCallContentType = LoadType( + "Microsoft.Extensions.AI.FunctionCallContent, Microsoft.Extensions.AI.Abstractions"); + private static readonly Type? McpServerToolCallContentType = LoadType( + "Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions"); + private static readonly Type? CodeInterpreterToolCallContentType = LoadType( + "Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions"); + private static readonly Type? ImageGenerationToolCallContentType = LoadType( + "Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions"); private readonly PatternValidator _patternValidator; public CopilotWorkflowRunner(PatternValidator patternValidator) @@ -21,6 +31,7 @@ public sealed class CopilotWorkflowRunner public async Task> RunTurnAsync( RunTurnCommandDto command, Func onDelta, + Func onActivity, CancellationToken cancellationToken) { PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault(); @@ -36,18 +47,50 @@ public sealed class CopilotWorkflowRunner List segments = []; int fallbackMessageIndex = 0; List completedMessages = []; + string? activeAgentId = null; + string? activeAgentName = null; await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) { - if (evt is AgentResponseUpdateEvent update && !string.IsNullOrEmpty(update.Update.Text)) + if (evt is ExecutorInvokedEvent invoked + && TryResolveKnownAgentName(command.Pattern, invoked.ExecutorId, out string invokedAgentName) + && !string.Equals(activeAgentId, invoked.ExecutorId, StringComparison.Ordinal)) + { + activeAgentId = invoked.ExecutorId; + activeAgentName = invokedAgentName; + + await onActivity(CreateActivityEvent( + command, + activityType: "thinking", + agentName: invokedAgentName)).ConfigureAwait(false); + } + else if (evt is RequestInfoEvent requestInfo) + { + AgentActivityEventDto? activity = TryCreateActivityFromRequest( + command, + requestInfo, + activeAgentName); + + if (activity is not null) + { + await onActivity(activity).ConfigureAwait(false); + } + } + else if (evt is AgentResponseUpdateEvent update && !string.IsNullOrEmpty(update.Update.Text)) { string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}"; StreamingSegment segment = GetOrCreateSegment(segments, messageId, update.ExecutorId); segment.Content.Append(update.Update.Text); + if (TryResolveKnownAgentName(command.Pattern, update.ExecutorId, out string updateAgentName)) + { + activeAgentId = update.ExecutorId; + activeAgentName = updateAgentName; + } + await onDelta(new TurnDeltaEventDto { Type = "turn-delta", @@ -58,6 +101,18 @@ public sealed class CopilotWorkflowRunner ContentDelta = update.Update.Text, }).ConfigureAwait(false); } + else if (evt is ExecutorCompletedEvent completed + && TryResolveKnownAgentName(command.Pattern, completed.ExecutorId, out string completedAgentName) + && string.Equals(activeAgentId, completed.ExecutorId, StringComparison.Ordinal)) + { + await onActivity(CreateActivityEvent( + command, + activityType: "completed", + agentName: completedAgentName)).ConfigureAwait(false); + + activeAgentId = null; + activeAgentName = null; + } else if (evt is WorkflowOutputEvent outputEvent) { List allMessages = outputEvent.As>() ?? []; @@ -69,6 +124,172 @@ public sealed class CopilotWorkflowRunner return completedMessages; } + private static AgentActivityEventDto CreateActivityEvent( + RunTurnCommandDto command, + string activityType, + string agentName, + string? toolName = null) + { + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = activityType, + AgentName = agentName, + ToolName = toolName, + }; + } + + private static AgentActivityEventDto? TryCreateActivityFromRequest( + RunTurnCommandDto command, + RequestInfoEvent requestInfo, + string? activeAgentName) + { + if (TryGetHandoffTargetName(command.Pattern, requestInfo, out string handoffAgentName)) + { + return CreateActivityEvent( + command, + activityType: "handoff", + agentName: handoffAgentName); + } + + if (string.IsNullOrWhiteSpace(activeAgentName) || !TryGetToolName(requestInfo, out string toolName)) + { + return null; + } + + return CreateActivityEvent( + command, + activityType: "tool-calling", + agentName: activeAgentName, + toolName: toolName); + } + + private static bool TryGetHandoffTargetName( + PatternDefinitionDto pattern, + RequestInfoEvent requestInfo, + out string agentName) + { + agentName = string.Empty; + if (!TryReadPortableValue(requestInfo.Request.Data, HandoffTargetType, out object? handoffTarget)) + { + return false; + } + + object? target = handoffTarget?.GetType().GetProperty("Target")?.GetValue(handoffTarget); + agentName = ResolveAgentName( + pattern, + GetStringProperty(target, "Id"), + GetStringProperty(target, "Name")); + return !string.IsNullOrWhiteSpace(agentName); + } + + private static bool TryGetToolName(RequestInfoEvent requestInfo, out string toolName) + { + if (TryReadPortableValue(requestInfo.Request.Data, FunctionCallContentType, out object? functionCall)) + { + toolName = GetStringProperty(functionCall, "Name") ?? "function"; + return true; + } + + if (TryReadPortableValue(requestInfo.Request.Data, McpServerToolCallContentType, out object? mcpToolCall)) + { + toolName = GetStringProperty(mcpToolCall, "ToolName") + ?? GetStringProperty(mcpToolCall, "ServerName") + ?? string.Empty; + return !string.IsNullOrWhiteSpace(toolName); + } + + if (TryReadPortableValue(requestInfo.Request.Data, CodeInterpreterToolCallContentType, out _)) + { + toolName = "code interpreter"; + return true; + } + + if (TryReadPortableValue(requestInfo.Request.Data, ImageGenerationToolCallContentType, out _)) + { + toolName = "image generation"; + return true; + } + + toolName = string.Empty; + return false; + } + + private static bool TryResolveKnownAgentName( + PatternDefinitionDto pattern, + string? agentIdentifier, + out string agentName) + { + agentName = string.Empty; + if (string.IsNullOrWhiteSpace(agentIdentifier)) + { + return false; + } + + PatternAgentDefinitionDto? match = pattern.Agents.FirstOrDefault(agent => + MatchesAgent(agent, agentIdentifier)); + + if (match is null) + { + return false; + } + + agentName = string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name; + return true; + } + + private static string ResolveAgentName( + PatternDefinitionDto pattern, + string? agentId, + string? agentName) + { + PatternAgentDefinitionDto? match = pattern.Agents.FirstOrDefault(agent => + MatchesAgent(agent, agentId) || MatchesAgent(agent, agentName)); + + if (match is not null) + { + return string.IsNullOrWhiteSpace(match.Name) ? match.Id : match.Name; + } + + if (!string.IsNullOrWhiteSpace(agentName)) + { + return agentName; + } + + return agentId ?? string.Empty; + } + + private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate) + { + return !string.IsNullOrWhiteSpace(candidate) + && (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase) + || string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase)); + } + + private static Type? LoadType(string assemblyQualifiedName) + { + return Type.GetType(assemblyQualifiedName, throwOnError: false); + } + + private static bool TryReadPortableValue(PortableValue portableValue, Type? targetType, out object? value) + { + value = null; + if (targetType is null || !portableValue.IsType(targetType)) + { + return false; + } + + value = portableValue.AsType(targetType); + return value is not null; + } + + private static string? GetStringProperty(object? instance, string propertyName) + { + return instance?.GetType().GetProperty(propertyName)?.GetValue(instance) as string; + } + private static StreamingSegment GetOrCreateSegment(List segments, string messageId, string authorName) { StreamingSegment? existing = segments.LastOrDefault(segment => segment.MessageId == messageId); diff --git a/sidecar/src/Kopaya.AgentHost/Services/ITurnWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/ITurnWorkflowRunner.cs new file mode 100644 index 0000000..13a5a54 --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/Services/ITurnWorkflowRunner.cs @@ -0,0 +1,12 @@ +using Kopaya.AgentHost.Contracts; + +namespace Kopaya.AgentHost.Services; + +public interface ITurnWorkflowRunner +{ + Task> RunTurnAsync( + RunTurnCommandDto command, + Func onDelta, + Func onActivity, + CancellationToken cancellationToken); +} diff --git a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs index 192b524..0c9caff 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs @@ -8,15 +8,20 @@ namespace Kopaya.AgentHost.Services; public sealed class SidecarProtocolHost { private readonly PatternValidator _patternValidator; - private readonly CopilotWorkflowRunner _workflowRunner; + private readonly ITurnWorkflowRunner _workflowRunner; private readonly JsonSerializerOptions _jsonOptions; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly ConcurrentDictionary _inFlight = new(StringComparer.Ordinal); public SidecarProtocolHost() + : this(new PatternValidator()) { - _patternValidator = new PatternValidator(); - _workflowRunner = new CopilotWorkflowRunner(_patternValidator); + } + + public SidecarProtocolHost(PatternValidator patternValidator, ITurnWorkflowRunner? workflowRunner = null) + { + _patternValidator = patternValidator; + _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator); _jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -102,6 +107,7 @@ public sealed class SidecarProtocolHost IReadOnlyList messages = await _workflowRunner.RunTurnAsync( runTurnCommand, delta => WriteAsync(output, delta, cancellationToken), + activity => WriteAsync(output, activity, cancellationToken), cancellationToken).ConfigureAwait(false); await WriteAsync(output, new TurnCompleteEventDto diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs index a0430b2..af674bc 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -90,16 +90,133 @@ public sealed class SidecarProtocolHostTests }); } - private static async Task> RunHostAsync(TCommand command) + [Fact] + public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion() + { + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, cancellationToken) => + { + await onActivity(new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = "thinking", + AgentName = "Primary", + }); + + await onDelta(new TurnDeltaEventDto + { + Type = "turn-delta", + RequestId = command.RequestId, + SessionId = command.SessionId, + MessageId = "assistant-1", + AuthorName = "Primary", + ContentDelta = "Hello", + }); + + await onActivity(new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = "tool-calling", + AgentName = "Primary", + ToolName = "read_file", + }); + + return + [ + new ChatMessageDto + { + Id = "assistant-1", + Role = "assistant", + AuthorName = "Primary", + Content = "Hello world", + CreatedAt = "2026-01-01T00:00:00.0000000Z", + }, + ]; + })); + + IReadOnlyList events = await RunHostAsync( + new RunTurnCommandDto + { + Type = "run-turn", + RequestId = "turn-1", + SessionId = "session-1", + ProjectPath = "C:\\workspace\\project", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(name: "Primary"), + ], + }, + Messages = + [ + new ChatMessageDto + { + Id = "user-1", + Role = "user", + AuthorName = "You", + Content = "Hello", + CreatedAt = "2026-01-01T00:00:00.0000000Z", + }, + ], + }, + host); + + Assert.Collection( + events, + thinkingEvent => + { + Assert.Equal("agent-activity", thinkingEvent.GetProperty("type").GetString()); + 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("Primary", thinkingEvent.GetProperty("agentName").GetString()); + }, + deltaEvent => + { + Assert.Equal("turn-delta", deltaEvent.GetProperty("type").GetString()); + Assert.Equal("Hello", deltaEvent.GetProperty("contentDelta").GetString()); + }, + toolEvent => + { + Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString()); + Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString()); + Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString()); + }, + completionEvent => + { + Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString()); + JsonElement[] messages = completionEvent.GetProperty("messages").EnumerateArray().ToArray(); + Assert.Single(messages); + Assert.Equal("Hello world", messages[0].GetProperty("content").GetString()); + }, + commandCompleteEvent => + { + Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); + Assert.Equal("turn-1", commandCompleteEvent.GetProperty("requestId").GetString()); + }); + } + + private static async Task> RunHostAsync( + object command, + SidecarProtocolHost? host = null) { string input = JsonSerializer.Serialize(command, JsonOptions) + Environment.NewLine; using StringReader reader = new(input); using StringWriter writer = new(); - SidecarProtocolHost host = new(); - await host.RunAsync(reader, writer, CancellationToken.None); - + await (host ?? new SidecarProtocolHost()).RunAsync(reader, writer, CancellationToken.None); return ParseEvents(writer.ToString()); } @@ -137,4 +254,34 @@ public sealed class SidecarProtocolHostTests Instructions = instructions, }; } + + private sealed class FakeWorkflowRunner : ITurnWorkflowRunner + { + private readonly Func< + RunTurnCommandDto, + Func, + Func, + CancellationToken, + Task>> _handler; + + public FakeWorkflowRunner( + Func< + RunTurnCommandDto, + Func, + Func, + CancellationToken, + Task>> handler) + { + _handler = handler; + } + + public Task> RunTurnAsync( + RunTurnCommandDto command, + Func onDelta, + Func onActivity, + CancellationToken cancellationToken) + { + return _handler(command, onDelta, onActivity, cancellationToken); + } + } } diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index b78867d..93a7c3d 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path'; import { dialog } from 'electron'; -import type { TurnDeltaEvent } from '@shared/contracts/sidecar'; +import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar'; import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern'; import type { ProjectRecord } from '@shared/domain/project'; import type { SessionEventRecord } from '@shared/domain/event'; @@ -199,6 +199,9 @@ export class KopayaAppService extends EventEmitter { async (event) => { await this.applyTurnDelta(workspace, session.id, event); }, + (event) => { + this.emitAgentActivity(event); + }, ); this.finalizeTurn(workspace, session.id, responseMessages); @@ -301,6 +304,17 @@ export class KopayaAppService extends EventEmitter { }); } + private emitAgentActivity(event: AgentActivityEvent): void { + this.emitSessionEvent({ + sessionId: event.sessionId, + kind: 'agent-activity', + occurredAt: nowIso(), + activityType: event.activityType, + agentName: event.agentName, + toolName: event.toolName, + }); + } + private finalizeTurn(workspace: WorkspaceState, sessionId: string, messages: ChatMessageRecord[]): void { const session = this.requireSession(workspace, sessionId); const incomingIds = new Set(messages.map((message) => message.id)); diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index e9421bb..6eb9844 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import type { + AgentActivityEvent, SidecarCapabilities, SidecarCommand, SidecarEvent, @@ -28,7 +29,8 @@ type PendingCommand = kind: 'run-turn'; resolve: (messages: ChatMessageRecord[]) => void; reject: (error: Error) => void; - onDelta: (event: TurnDeltaEvent) => void; + onDelta: (event: TurnDeltaEvent) => void | Promise; + onActivity: (event: AgentActivityEvent) => void | Promise; }; export class SidecarClient { @@ -53,8 +55,12 @@ export class SidecarClient { }); } - async runTurn(command: RunTurnCommand, onDelta: (event: TurnDeltaEvent) => void): Promise { - return this.dispatch(command, onDelta); + async runTurn( + command: RunTurnCommand, + onDelta: (event: TurnDeltaEvent) => void | Promise, + onActivity: (event: AgentActivityEvent) => void | Promise, + ): Promise { + return this.dispatch(command, onDelta, onActivity); } async dispose(): Promise { @@ -110,7 +116,8 @@ export class SidecarClient { private async dispatch( command: SidecarCommand, - onDelta?: (event: TurnDeltaEvent) => void, + onDelta?: (event: TurnDeltaEvent) => void | Promise, + onActivity?: (event: AgentActivityEvent) => void | Promise, ): Promise { const process = await this.ensureProcess(); @@ -121,6 +128,7 @@ export class SidecarClient { resolve: resolve as (messages: ChatMessageRecord[]) => void, reject, onDelta: onDelta ?? (() => undefined), + onActivity: onActivity ?? (() => undefined), }); } else if (command.type === 'validate-pattern') { this.pending.set(command.requestId, { @@ -176,7 +184,12 @@ export class SidecarClient { return; case 'turn-delta': if (pending.kind === 'run-turn') { - pending.onDelta(event); + this.invokeRunTurnHandler(event.requestId, pending, () => pending.onDelta(event)); + } + return; + case 'agent-activity': + if (pending.kind === 'run-turn') { + this.invokeRunTurnHandler(event.requestId, pending, () => pending.onActivity(event)); } return; case 'turn-complete': @@ -196,4 +209,15 @@ export class SidecarClient { return; } } + + private invokeRunTurnHandler( + requestId: string, + pending: Extract, + callback: () => void | Promise, + ): void { + void Promise.resolve(callback()).catch((error: unknown) => { + this.pending.delete(requestId); + pending.reject(error instanceof Error ? error : new Error(String(error))); + }); + } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0919573..31b46c3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,6 +5,11 @@ import { ChatPane } from '@renderer/components/ChatPane'; import { NewSessionModal } from '@renderer/components/NewSessionModal'; import { SettingsPanel } from '@renderer/components/SettingsPanel'; import { Sidebar } from '@renderer/components/Sidebar'; +import { + applySessionEventActivity, + pruneSessionActivities, + type SessionActivityMap, +} from '@renderer/lib/sessionActivity'; import { WelcomePane } from '@renderer/components/WelcomePane'; import { getElectronApi } from '@renderer/lib/electronApi'; import type { PatternDefinition } from '@shared/domain/pattern'; @@ -39,6 +44,7 @@ export default function App() { const api = getElectronApi(); const [workspace, setWorkspace] = useState(); const [error, setError] = useState(); + const [sessionActivities, setSessionActivities] = useState({}); const [showSettings, setShowSettings] = useState(false); const [showNewSession, setShowNewSession] = useState(false); @@ -55,11 +61,22 @@ export default function App() { const offWorkspace = api.onWorkspaceUpdated((ws) => { setWorkspace(ws); setError(undefined); + setSessionActivities((current) => + pruneSessionActivities( + current, + ws.sessions.map((session) => session.id), + ), + ); + }); + + const offSessionEvent = api.onSessionEvent((event) => { + setSessionActivities((current) => applySessionEventActivity(current, event)); }); return () => { disposed = true; offWorkspace(); + offSessionEvent(); }; }, [api]); @@ -82,6 +99,10 @@ export default function App() { : undefined, [selectedSession, workspace?.projects], ); + const activityForSession = useMemo( + () => (selectedSession ? sessionActivities[selectedSession.id] : undefined), + [selectedSession, sessionActivities], + ); // Loading state if (!workspace) { @@ -106,6 +127,7 @@ export default function App() { } else if (selectedSession && patternForSession && projectForSession) { content = ( api.sendSessionMessage({ sessionId: selectedSession.id, content: c })} pattern={patternForSession} project={projectForSession} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index d6ccae5..24db3b0 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,6 +1,11 @@ import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react'; +import { + formatSessionActivityLabel, + shouldAnimateSessionActivity, + type SessionActivityState, +} from '@renderer/lib/sessionActivity'; import type { PatternDefinition } from '@shared/domain/pattern'; import type { ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; @@ -16,13 +21,14 @@ function ThinkingDots() { } interface ChatPaneProps { + activity?: SessionActivityState; project: ProjectRecord; pattern: PatternDefinition; session: SessionRecord; onSend: (content: string) => Promise; } -export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) { +export function ChatPane({ activity, project, pattern, session, onSend }: ChatPaneProps) { const [input, setInput] = useState(''); const transcriptRef = useRef(null); const textareaRef = useRef(null); @@ -30,6 +36,8 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) { 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); useEffect(() => { transcriptRef.current?.scrollTo({ @@ -136,10 +144,8 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
-
- {pattern.agents[0]?.name ?? 'Agent'} -
- +
{activityLabel}
+ {showActivityAnimation && }
diff --git a/src/renderer/lib/sessionActivity.ts b/src/renderer/lib/sessionActivity.ts new file mode 100644 index 0000000..1ddd5aa --- /dev/null +++ b/src/renderer/lib/sessionActivity.ts @@ -0,0 +1,95 @@ +import type { SessionEventRecord } from '@shared/domain/event'; + +export interface SessionActivityState { + sessionId: string; + activityType?: SessionEventRecord['activityType']; + agentName?: string; + toolName?: string; +} + +export type SessionActivityMap = Record; + +export function applySessionEventActivity( + current: SessionActivityMap, + event: SessionEventRecord, +): SessionActivityMap { + if (event.kind === 'agent-activity') { + return { + ...current, + [event.sessionId]: { + sessionId: event.sessionId, + activityType: event.activityType, + agentName: event.agentName, + toolName: event.toolName, + }, + }; + } + + if (event.kind === 'status') { + if (event.status === 'running' || event.status === 'idle' || event.status === 'error') { + return removeSessionActivity(current, event.sessionId); + } + } + + if (event.kind === 'error') { + return removeSessionActivity(current, event.sessionId); + } + + return current; +} + +export function pruneSessionActivities( + current: SessionActivityMap, + sessionIds: Iterable, +): SessionActivityMap { + const allowed = new Set(sessionIds); + const next: SessionActivityMap = {}; + let changed = false; + + for (const [sessionId, activity] of Object.entries(current)) { + if (!allowed.has(sessionId)) { + changed = true; + continue; + } + + next[sessionId] = activity; + } + + 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; + + switch (activity?.activityType) { + case 'tool-calling': + return `${agentName} is using ${activity.toolName?.trim() || 'a tool'}…`; + case 'handoff': + return `Handing off to ${agentName}…`; + case 'completed': + return `${agentName} completed their turn.`; + case 'thinking': + default: + return `${agentName} is thinking…`; + } +} + +export function shouldAnimateSessionActivity(activity: SessionActivityState | undefined): boolean { + return activity?.activityType !== 'completed'; +} + +function removeSessionActivity( + current: SessionActivityMap, + sessionId: string, +): SessionActivityMap { + if (!(sessionId in current)) { + return current; + } + + const next = { ...current }; + delete next[sessionId]; + return next; +} diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 3dc630d..5ec5f9f 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -61,6 +61,17 @@ export interface TurnCompleteEvent { messages: ChatMessageRecord[]; } +export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; + +export interface AgentActivityEvent { + type: 'agent-activity'; + requestId: string; + sessionId: string; + activityType: AgentActivityType; + agentName?: string; + toolName?: string; +} + export interface CommandErrorEvent { type: 'command-error'; requestId: string; @@ -77,5 +88,6 @@ export type SidecarEvent = | PatternValidationEvent | TurnDeltaEvent | TurnCompleteEvent + | AgentActivityEvent | CommandErrorEvent | CommandCompleteEvent; diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 341338c..85b13ac 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -1,4 +1,11 @@ -export type SessionEventKind = 'status' | 'message-delta' | 'message-complete' | 'error'; +export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; + +export type SessionEventKind = + | 'status' + | 'message-delta' + | 'message-complete' + | 'agent-activity' + | 'error'; export interface SessionEventRecord { sessionId: string; @@ -8,5 +15,8 @@ export interface SessionEventRecord { messageId?: string; authorName?: string; contentDelta?: string; + activityType?: SessionActivityType; + agentName?: string; + toolName?: string; error?: string; } diff --git a/tests/renderer/sessionActivity.test.ts b/tests/renderer/sessionActivity.test.ts new file mode 100644 index 0000000..ef0d4a5 --- /dev/null +++ b/tests/renderer/sessionActivity.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from 'bun:test'; + +import { + applySessionEventActivity, + formatSessionActivityLabel, + pruneSessionActivities, + shouldAnimateSessionActivity, + type SessionActivityMap, +} from '@renderer/lib/sessionActivity'; +import type { SessionEventRecord } from '@shared/domain/event'; + +describe('session activity helpers', () => { + test('stores the latest agent activity by session', () => { + const event: SessionEventRecord = { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'tool-calling', + agentName: 'Code Reviewer', + toolName: 'read_file', + }; + + expect(applySessionEventActivity({}, event)).toEqual({ + 'session-1': { + sessionId: 'session-1', + activityType: 'tool-calling', + agentName: 'Code Reviewer', + toolName: 'read_file', + }, + }); + }); + + test('clears stale activity when a session restarts or finishes', () => { + const current: SessionActivityMap = { + 'session-1': { + sessionId: 'session-1', + activityType: 'thinking', + agentName: 'Primary', + }, + 'session-2': { + sessionId: 'session-2', + activityType: 'handoff', + agentName: 'Reviewer', + }, + }; + + expect( + applySessionEventActivity(current, { + sessionId: 'session-1', + kind: 'status', + occurredAt: '2026-03-23T00:00:00.000Z', + status: 'running', + }), + ).toEqual({ + 'session-2': current['session-2'], + }); + + expect( + applySessionEventActivity(current, { + sessionId: 'session-2', + kind: 'error', + occurredAt: '2026-03-23T00:00:00.000Z', + error: 'Boom', + }), + ).toEqual({ + 'session-1': current['session-1'], + }); + }); + + test('formats contextual activity labels and animation state', () => { + expect(formatSessionActivityLabel(undefined, 'Primary')).toBe('Primary is thinking…'); + expect( + formatSessionActivityLabel( + { + sessionId: 'session-1', + activityType: 'tool-calling', + agentName: 'Reviewer', + toolName: 'read_file', + }, + 'Primary', + ), + ).toBe('Reviewer is using read_file…'); + expect( + formatSessionActivityLabel( + { + sessionId: 'session-1', + activityType: 'handoff', + agentName: 'Summarizer', + }, + 'Primary', + ), + ).toBe('Handing off to Summarizer…'); + expect( + formatSessionActivityLabel( + { + sessionId: 'session-1', + activityType: 'completed', + agentName: 'Reviewer', + }, + 'Primary', + ), + ).toBe('Reviewer completed their turn.'); + expect( + shouldAnimateSessionActivity({ + sessionId: 'session-1', + activityType: 'thinking', + }), + ).toBe(true); + expect( + shouldAnimateSessionActivity({ + sessionId: 'session-1', + activityType: 'completed', + }), + ).toBe(false); + }); + + test('prunes activity state for sessions that no longer exist', () => { + const current: SessionActivityMap = { + 'session-1': { + sessionId: 'session-1', + activityType: 'thinking', + }, + 'session-2': { + sessionId: 'session-2', + activityType: 'tool-calling', + toolName: 'read_file', + }, + }; + + expect(pruneSessionActivities(current, ['session-2'])).toEqual({ + 'session-2': current['session-2'], + }); + }); +});