mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-26 22:48:38 +02:00
feat: add agent activity events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+22
-12
@@ -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 |
|
||||
|
||||
@@ -105,6 +105,14 @@ public sealed class TurnCompleteEventDto : SidecarEventDto
|
||||
public IReadOnlyList<ChatMessageDto> 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;
|
||||
|
||||
@@ -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<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
@@ -36,18 +47,50 @@ public sealed class CopilotWorkflowRunner
|
||||
List<StreamingSegment> segments = [];
|
||||
int fallbackMessageIndex = 0;
|
||||
List<ChatMessageDto> 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<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
|
||||
@@ -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<StreamingSegment> segments, string messageId, string authorName)
|
||||
{
|
||||
StreamingSegment? existing = segments.LastOrDefault(segment => segment.MessageId == messageId);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Kopaya.AgentHost.Contracts;
|
||||
|
||||
namespace Kopaya.AgentHost.Services;
|
||||
|
||||
public interface ITurnWorkflowRunner
|
||||
{
|
||||
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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<string, Task> _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<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
|
||||
runTurnCommand,
|
||||
delta => WriteAsync(output, delta, cancellationToken),
|
||||
activity => WriteAsync(output, activity, cancellationToken),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(output, new TurnCompleteEventDto
|
||||
|
||||
@@ -90,16 +90,133 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync<TCommand>(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<JsonElement> 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<IReadOnlyList<JsonElement>> 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<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> _handler;
|
||||
|
||||
public FakeWorkflowRunner(
|
||||
Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _handler(command, onDelta, onActivity, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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<void>;
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export class SidecarClient {
|
||||
@@ -53,8 +55,12 @@ export class SidecarClient {
|
||||
});
|
||||
}
|
||||
|
||||
async runTurn(command: RunTurnCommand, onDelta: (event: TurnDeltaEvent) => void): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta);
|
||||
async runTurn(
|
||||
command: RunTurnCommand,
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
@@ -110,7 +116,8 @@ export class SidecarClient {
|
||||
|
||||
private async dispatch<TResult>(
|
||||
command: SidecarCommand,
|
||||
onDelta?: (event: TurnDeltaEvent) => void,
|
||||
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
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<PendingCommand, { kind: 'run-turn' }>,
|
||||
callback: () => void | Promise<void>,
|
||||
): void {
|
||||
void Promise.resolve(callback()).catch((error: unknown) => {
|
||||
this.pending.delete(requestId);
|
||||
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceState>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
||||
|
||||
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 = (
|
||||
<ChatPane
|
||||
activity={activityForSession}
|
||||
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
|
||||
pattern={patternForSession}
|
||||
project={projectForSession}
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
|
||||
export function ChatPane({ activity, project, pattern, session, onSend }: ChatPaneProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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) {
|
||||
<Bot className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 text-[12px] font-medium text-zinc-500">
|
||||
{pattern.agents[0]?.name ?? 'Agent'}
|
||||
</div>
|
||||
<ThinkingDots />
|
||||
<div className="mb-1.5 text-[12px] font-medium text-zinc-500">{activityLabel}</div>
|
||||
{showActivityAnimation && <ThinkingDots />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, SessionActivityState | undefined>;
|
||||
|
||||
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<string>,
|
||||
): 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user