fix: improve agent activity reporting

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 13:41:09 +01:00
co-authored by Copilot
parent b28a955271
commit e6826f1324
5 changed files with 102 additions and 28 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ The Electron main process maps this to a `SessionEventRecord` with `kind: 'agent
- "Code Reviewer is using read_file…"
- "Handing off to Summarizer…"
The activity panel in `ChatPane.tsx` is now wired to this data, showing every agent in the pattern with observed activity such as thinking, tool usage, handoff, or completed. If no event has been observed for an agent yet, the UI now states that no status has been reported instead of inventing a synthetic state.
The activity panel in `ChatPane.tsx` is now wired to this data, showing every agent in the pattern with observed activity such as thinking, tool usage, handoff, or completed. If no event has been observed for an agent yet, the UI states that no status has been reported instead of inventing a synthetic state. The panel also keeps the last observed statuses visible after a run completes, and resets them when the next run begins.
## Files involved
@@ -50,6 +50,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
int fallbackMessageIndex = 0;
List<ChatMessageDto> completedMessages = [];
AgentIdentity? activeAgent = null;
HashSet<string> startedAgents = new(StringComparer.OrdinalIgnoreCase);
HashSet<string> completedAgents = new(StringComparer.OrdinalIgnoreCase);
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
@@ -57,16 +59,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
{
if (evt is ExecutorInvokedEvent invoked
&& TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent)
&& (!activeAgent.HasValue
|| !string.Equals(activeAgent.Value.AgentId, invokedAgent.AgentId, StringComparison.Ordinal)))
&& TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent))
{
activeAgent = invokedAgent;
await onActivity(CreateActivityEvent(
await EmitThinkingIfNeeded(
command,
activityType: "thinking",
agent: invokedAgent)).ConfigureAwait(false);
invokedAgent,
startedAgents,
onActivity).ConfigureAwait(false);
}
else if (evt is RequestInfoEvent requestInfo)
{
@@ -89,6 +89,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent))
{
activeAgent = updateAgent;
await EmitThinkingIfNeeded(
command,
updateAgent,
startedAgents,
onActivity).ConfigureAwait(false);
}
await onDelta(new TurnDeltaEventDto
@@ -102,22 +107,32 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}).ConfigureAwait(false);
}
else if (evt is ExecutorCompletedEvent completed
&& TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent)
&& activeAgent.HasValue
&& string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
&& TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent))
{
await onActivity(CreateActivityEvent(
command,
activityType: "completed",
agent: completedAgent)).ConfigureAwait(false);
if (completedAgents.Add(completedAgent.AgentId))
{
await onActivity(CreateActivityEvent(
command,
activityType: "completed",
agent: completedAgent)).ConfigureAwait(false);
}
activeAgent = null;
if (activeAgent.HasValue
&& string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{
activeAgent = null;
}
}
else if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
List<ChatMessage> newMessages = allMessages.Skip(inputMessages.Count).ToList();
completedMessages = ConvertOutputMessages(command, newMessages, segments);
await EmitCompletedActivitiesForMessages(
command,
completedMessages,
completedAgents,
onActivity).ConfigureAwait(false);
}
}
@@ -167,6 +182,48 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
toolName: toolName);
}
private static async Task EmitThinkingIfNeeded(
RunTurnCommandDto command,
AgentIdentity agent,
ISet<string> startedAgents,
Func<AgentActivityEventDto, Task> onActivity)
{
if (!startedAgents.Add(agent.AgentId))
{
return;
}
await onActivity(CreateActivityEvent(
command,
activityType: "thinking",
agent: agent)).ConfigureAwait(false);
}
private static async Task EmitCompletedActivitiesForMessages(
RunTurnCommandDto command,
IReadOnlyList<ChatMessageDto> messages,
ISet<string> completedAgents,
Func<AgentActivityEventDto, Task> onActivity)
{
foreach (ChatMessageDto message in messages)
{
if (!TryResolveKnownAgentIdentity(command.Pattern, message.AuthorName, out AgentIdentity messageAgent))
{
continue;
}
if (!completedAgents.Add(messageAgent.AgentId))
{
continue;
}
await onActivity(CreateActivityEvent(
command,
activityType: "completed",
agent: messageAgent)).ConfigureAwait(false);
}
}
private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern,
RequestInfoEvent requestInfo,
+3 -1
View File
@@ -40,6 +40,8 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
() => buildAgentActivityRows(activity, pattern.agents),
[activity, pattern.agents],
);
const hasObservedActivity = activityRows.some((row) => !!row.activity);
const showActivityPanel = (isBusy || hasObservedActivity) && activityRows.length > 0;
useEffect(() => {
transcriptRef.current?.scrollTo({
@@ -138,7 +140,7 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
})}
</div>
{isBusy && activityRows.length > 0 && (
{showActivityPanel && (
<div className="mb-4 rounded-xl border border-zinc-800 bg-zinc-900/60 px-4 py-3">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-600">
Agent activity
+1 -5
View File
@@ -42,15 +42,11 @@ export function applySessionEventActivity(
}
if (event.kind === 'status') {
if (event.status === 'running' || event.status === 'idle' || event.status === 'error') {
if (event.status === 'running') {
return removeSessionActivity(current, event.sessionId);
}
}
if (event.kind === 'error') {
return removeSessionActivity(current, event.sessionId);
}
return current;
}
+25 -6
View File
@@ -83,7 +83,7 @@ describe('session activity helpers', () => {
});
});
test('clears stale activity when a session restarts or finishes', () => {
test('clears stale activity when a session restarts', () => {
const current: SessionActivityMap = {
'session-1': {
architect: {
@@ -111,17 +111,36 @@ describe('session activity helpers', () => {
).toEqual({
'session-2': current['session-2'],
});
});
test('keeps the last observed status after completion or error', () => {
const current: SessionActivityMap = {
'session-1': {
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'completed',
},
},
};
expect(
applySessionEventActivity(current, {
sessionId: 'session-2',
kind: 'error',
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:00.000Z',
status: 'idle',
}),
).toEqual(current);
expect(
applySessionEventActivity(current, {
sessionId: 'session-1',
kind: 'error',
occurredAt: '2026-03-23T00:00:01.000Z',
error: 'Boom',
}),
).toEqual({
'session-1': current['session-1'],
});
).toEqual(current);
});
test('builds rows for all agents with sensible defaults', () => {