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…" - "Code Reviewer is using read_file…"
- "Handing off to Summarizer…" - "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 ## Files involved
@@ -50,6 +50,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
int fallbackMessageIndex = 0; int fallbackMessageIndex = 0;
List<ChatMessageDto> completedMessages = []; List<ChatMessageDto> completedMessages = [];
AgentIdentity? activeAgent = null; 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 using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
@@ -57,16 +59,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
{ {
if (evt is ExecutorInvokedEvent invoked if (evt is ExecutorInvokedEvent invoked
&& TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent) && TryResolveKnownAgentIdentity(command.Pattern, invoked.ExecutorId, out AgentIdentity invokedAgent))
&& (!activeAgent.HasValue
|| !string.Equals(activeAgent.Value.AgentId, invokedAgent.AgentId, StringComparison.Ordinal)))
{ {
activeAgent = invokedAgent; activeAgent = invokedAgent;
await EmitThinkingIfNeeded(
await onActivity(CreateActivityEvent(
command, command,
activityType: "thinking", invokedAgent,
agent: invokedAgent)).ConfigureAwait(false); startedAgents,
onActivity).ConfigureAwait(false);
} }
else if (evt is RequestInfoEvent requestInfo) else if (evt is RequestInfoEvent requestInfo)
{ {
@@ -89,6 +89,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent)) if (TryResolveKnownAgentIdentity(command.Pattern, update.ExecutorId, out AgentIdentity updateAgent))
{ {
activeAgent = updateAgent; activeAgent = updateAgent;
await EmitThinkingIfNeeded(
command,
updateAgent,
startedAgents,
onActivity).ConfigureAwait(false);
} }
await onDelta(new TurnDeltaEventDto await onDelta(new TurnDeltaEventDto
@@ -102,22 +107,32 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
else if (evt is ExecutorCompletedEvent completed else if (evt is ExecutorCompletedEvent completed
&& TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent) && TryResolveKnownAgentIdentity(command.Pattern, completed.ExecutorId, out AgentIdentity completedAgent))
&& activeAgent.HasValue
&& string.Equals(activeAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{ {
await onActivity(CreateActivityEvent( if (completedAgents.Add(completedAgent.AgentId))
command, {
activityType: "completed", await onActivity(CreateActivityEvent(
agent: completedAgent)).ConfigureAwait(false); 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) else if (evt is WorkflowOutputEvent outputEvent)
{ {
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? []; List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
List<ChatMessage> newMessages = allMessages.Skip(inputMessages.Count).ToList(); List<ChatMessage> newMessages = allMessages.Skip(inputMessages.Count).ToList();
completedMessages = ConvertOutputMessages(command, newMessages, segments); completedMessages = ConvertOutputMessages(command, newMessages, segments);
await EmitCompletedActivitiesForMessages(
command,
completedMessages,
completedAgents,
onActivity).ConfigureAwait(false);
} }
} }
@@ -167,6 +182,48 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
toolName: toolName); 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( private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern, PatternDefinitionDto pattern,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
+3 -1
View File
@@ -40,6 +40,8 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
() => buildAgentActivityRows(activity, pattern.agents), () => buildAgentActivityRows(activity, pattern.agents),
[activity, pattern.agents], [activity, pattern.agents],
); );
const hasObservedActivity = activityRows.some((row) => !!row.activity);
const showActivityPanel = (isBusy || hasObservedActivity) && activityRows.length > 0;
useEffect(() => { useEffect(() => {
transcriptRef.current?.scrollTo({ transcriptRef.current?.scrollTo({
@@ -138,7 +140,7 @@ export function ChatPane({ activity, project, pattern, session, onSend }: ChatPa
})} })}
</div> </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-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"> <div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-600">
Agent activity Agent activity
+1 -5
View File
@@ -42,15 +42,11 @@ export function applySessionEventActivity(
} }
if (event.kind === 'status') { if (event.kind === 'status') {
if (event.status === 'running' || event.status === 'idle' || event.status === 'error') { if (event.status === 'running') {
return removeSessionActivity(current, event.sessionId); return removeSessionActivity(current, event.sessionId);
} }
} }
if (event.kind === 'error') {
return removeSessionActivity(current, event.sessionId);
}
return current; 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 = { const current: SessionActivityMap = {
'session-1': { 'session-1': {
architect: { architect: {
@@ -111,17 +111,36 @@ describe('session activity helpers', () => {
).toEqual({ ).toEqual({
'session-2': current['session-2'], '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( expect(
applySessionEventActivity(current, { applySessionEventActivity(current, {
sessionId: 'session-2', sessionId: 'session-1',
kind: 'error', kind: 'status',
occurredAt: '2026-03-23T00:00:00.000Z', 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', error: 'Boom',
}), }),
).toEqual({ ).toEqual(current);
'session-1': current['session-1'],
});
}); });
test('builds rows for all agents with sensible defaults', () => { test('builds rows for all agents with sensible defaults', () => {