diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a5ebcb3..b441b6a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -214,6 +214,8 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt - **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined - **Skill invocation events**: emitted when an agent-side skill is triggered +- **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming +- **Assistant intent and reasoning-delta events**: optional Copilot SDK metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces - **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity - **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream - **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index e38d1bb..ca74389 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -331,6 +331,13 @@ public sealed class TurnCompleteEventDto : SidecarEventDto public bool Cancelled { get; init; } } +public sealed class MessageReclassifiedEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string MessageId { get; init; } = string.Empty; + public string NewKind { get; init; } = string.Empty; +} + public sealed class AgentActivityEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; @@ -376,6 +383,23 @@ public sealed class SkillInvokedEventDto : SidecarEventDto public string? Description { get; init; } } +public sealed class AssistantIntentEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? AgentName { get; init; } + public string Intent { get; init; } = string.Empty; +} + +public sealed class ReasoningDeltaEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? AgentName { get; init; } + public string ReasoningId { get; init; } = string.Empty; + public string ContentDelta { get; init; } = string.Empty; +} + public sealed class HookLifecycleEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs index 89f6d63..5822912 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -9,11 +9,13 @@ internal sealed class CopilotTurnExecutionState { private readonly RunTurnCommandDto _command; private readonly HashSet _startedAgents = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _reclassifiedMessageIds = new(StringComparer.Ordinal); private readonly ConcurrentQueue _pendingEvents = new(); private readonly ConcurrentQueue _pendingMcpOauthRequests = new(); private readonly ConcurrentDictionary _observedAgentsByMessageId = new(StringComparer.Ordinal); private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); private int _fallbackMessageIndex; + private string? _lastObservedMessageId; public CopilotTurnExecutionState(RunTurnCommandDto command) { @@ -79,15 +81,34 @@ internal sealed class CopilotTurnExecutionState case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId): RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId); QueueThinkingIfNeeded(agent); + if (assistantMessage.Data?.ToolRequests is { Length: > 0 }) + { + QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId); + } break; case ToolExecutionStartEvent toolExecutionStart when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId) && !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName): ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim(); + QueueMessageReclassifiedIfNeeded(_lastObservedMessageId); break; - case AssistantReasoningDeltaEvent: + case AssistantIntentEvent intentEvent: ActiveAgent = agent; QueueThinkingIfNeeded(agent); + AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Data); + if (assistantIntent is not null) + { + _pendingEvents.Enqueue(assistantIntent); + } + break; + case AssistantReasoningDeltaEvent reasoningDelta: + ActiveAgent = agent; + QueueThinkingIfNeeded(agent); + ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(agent, reasoningDelta.Data); + if (reasoningDeltaEvent is not null) + { + _pendingEvents.Enqueue(reasoningDeltaEvent); + } break; case SubagentStartedEvent started: ActiveAgent = agent; @@ -218,6 +239,23 @@ internal sealed class CopilotTurnExecutionState { ActiveAgent = agent; _observedAgentsByMessageId[messageId] = agent; + _lastObservedMessageId = messageId; + } + + private void QueueMessageReclassifiedIfNeeded(string? messageId) + { + if (string.IsNullOrWhiteSpace(messageId)) + { + return; + } + + string normalizedMessageId = messageId.Trim(); + if (!_reclassifiedMessageIds.Add(normalizedMessageId)) + { + return; + } + + _pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId)); } private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent) @@ -240,6 +278,18 @@ internal sealed class CopilotTurnExecutionState }; } + private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId) + { + return new MessageReclassifiedEventDto + { + Type = "message-reclassified", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + MessageId = messageId, + NewKind = "thinking", + }; + } + public void UpdateCompletedMessages( IReadOnlyList allMessages, IReadOnlyList inputMessages) @@ -354,6 +404,50 @@ internal sealed class CopilotTurnExecutionState }; } + private AssistantIntentEventDto? CreateAssistantIntentEvent( + AgentIdentity agent, + AssistantIntentData? data) + { + string? intent = data?.Intent?.Trim(); + if (string.IsNullOrWhiteSpace(intent)) + { + return null; + } + + return new AssistantIntentEventDto + { + Type = "assistant-intent", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + AgentId = agent.AgentId, + AgentName = agent.AgentName, + Intent = intent, + }; + } + + private ReasoningDeltaEventDto? CreateReasoningDeltaEvent( + AgentIdentity agent, + AssistantReasoningDeltaData? data) + { + if (data is null + || string.IsNullOrWhiteSpace(data.ReasoningId) + || string.IsNullOrEmpty(data.DeltaContent)) + { + return null; + } + + return new ReasoningDeltaEventDto + { + Type = "reasoning-delta", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + AgentId = agent.AgentId, + AgentName = agent.AgentName, + ReasoningId = data.ReasoningId, + ContentDelta = data.DeltaContent, + }; + } + private SkillInvokedEventDto CreateSkillInvokedEvent( AgentIdentity agent, SkillInvokedData? data) diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index 98dca66..53ea0b1 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -83,6 +83,132 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("view", toolName); } + [Fact] + public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.message", + "data": { + "messageId": "msg-2", + "content": "Let me search for that.", + "toolRequests": [ + { + "toolCallId": "tool-call-1", + "name": "rg", + "arguments": { + "pattern": "identifierUri" + } + } + ] + }, + "id": "3f75988b-8e69-4c90-a203-6b01d1c1f90b", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + IReadOnlyList pending = state.DrainPendingEvents(); + + AgentActivityEventDto thinking = Assert.Single(pending.OfType()); + Assert.Equal("thinking", thinking.ActivityType); + + MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType()); + Assert.Equal("session-1", reclassified.SessionId); + Assert.Equal("msg-2", reclassified.MessageId); + Assert.Equal("thinking", reclassified.NewKind); + } + + [Fact] + public void ObserveSessionEvent_ToolExecutionStart_ReclassifiesLastObservedMessageOnce() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.message_delta", + "data": { + "messageId": "msg-3", + "deltaContent": "Searching" + }, + "id": "0b65f0e9-d0fb-417e-ab5c-7a3343d8581b", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + _ = state.DrainPendingEvents(); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "tool.execution_start", + "data": { + "toolCallId": "tool-call-1", + "toolName": "rg" + }, + "id": "8f33240e-bd3f-475c-aeb6-a4b7908e47b0", + "timestamp": "2026-03-27T00:00:01Z" + } + """)); + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "tool.execution_start", + "data": { + "toolCallId": "tool-call-2", + "toolName": "view" + }, + "id": "a23f9c9a-f947-4282-866d-f599451c3899", + "timestamp": "2026-03-27T00:00:02Z" + } + """)); + + IReadOnlyList pending = state.DrainPendingEvents(); + + MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType()); + Assert.Equal("msg-3", reclassified.MessageId); + Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName)); + Assert.Equal("rg", firstToolName); + Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName)); + Assert.Equal("view", secondToolName); + } + + [Fact] + public void ObserveSessionEvent_AssistantMessageWithoutToolRequests_DoesNotQueueMessageReclassifiedEvent() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.message", + "data": { + "messageId": "msg-4", + "content": "Final answer." + }, + "id": "d07fe954-1258-4f6a-bf79-1550d6143ed0", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + Assert.Empty(state.DrainPendingEvents().OfType()); + } + [Fact] public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity() { @@ -119,6 +245,70 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("agent-1", thinking.AgentId); } + [Fact] + public void ObserveSessionEvent_AssistantIntent_QueuesIntentEvent() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.intent", + "data": { + "intent": "Searching incident playbooks" + }, + "id": "64cf59fe-63f0-4217-adf4-9bd6b3a80452", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + IReadOnlyList pending = state.DrainPendingEvents(); + + AgentActivityEventDto thinking = Assert.Single(pending.OfType()); + Assert.Equal("thinking", thinking.ActivityType); + + AssistantIntentEventDto intent = Assert.Single(pending.OfType()); + Assert.Equal("session-1", intent.SessionId); + Assert.Equal("agent-1", intent.AgentId); + Assert.Equal("Searching incident playbooks", intent.Intent); + } + + [Fact] + public void ObserveSessionEvent_AssistantReasoningDelta_QueuesReasoningDeltaEvent() + { + RunTurnCommandDto command = CreateCommand(); + CopilotTurnExecutionState state = new(command); + + state.ObserveSessionEvent( + command.Pattern.Agents[0], + SessionEvent.FromJson( + """ + { + "type": "assistant.reasoning_delta", + "data": { + "reasoningId": "reasoning-2", + "deltaContent": "Searching logs." + }, + "id": "bd269258-5e5d-46b6-bf3f-bd8cba793b1a", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + IReadOnlyList pending = state.DrainPendingEvents(); + + AgentActivityEventDto thinking = Assert.Single(pending.OfType()); + Assert.Equal("thinking", thinking.ActivityType); + + ReasoningDeltaEventDto reasoning = Assert.Single(pending.OfType()); + Assert.Equal("session-1", reasoning.SessionId); + Assert.Equal("agent-1", reasoning.AgentId); + Assert.Equal("reasoning-2", reasoning.ReasoningId); + Assert.Equal("Searching logs.", reasoning.ContentDelta); + } + [Fact] public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue() { diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 0ec86f3..003ac36 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -239,6 +239,14 @@ export interface TurnCompleteEvent { cancelled?: boolean; } +export interface MessageReclassifiedEvent { + type: 'message-reclassified'; + requestId: string; + sessionId: string; + messageId: string; + newKind: 'thinking'; +} + export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; export interface ToolCallFileChangePreview { @@ -297,6 +305,25 @@ export interface SkillInvokedEvent { description?: string; } +export interface AssistantIntentEvent { + type: 'assistant-intent'; + requestId: string; + sessionId: string; + agentId?: string; + agentName?: string; + intent: string; +} + +export interface ReasoningDeltaEvent { + type: 'reasoning-delta'; + requestId: string; + sessionId: string; + agentId?: string; + agentName?: string; + reasoningId: string; + contentDelta: string; +} + export interface HookLifecycleEvent { type: 'hook-lifecycle'; requestId: string; @@ -526,9 +553,12 @@ export type SidecarEvent = | PatternValidationEvent | TurnDeltaEvent | TurnCompleteEvent + | MessageReclassifiedEvent | AgentActivityEvent | SubagentEvent | SkillInvokedEvent + | AssistantIntentEvent + | ReasoningDeltaEvent | HookLifecycleEvent | SessionUsageEvent | SessionCompactionEvent diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index fec5783..d2a037c 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -1,4 +1,5 @@ import type { SessionRunRecord } from '@shared/domain/runTimeline'; +import type { ChatMessageKind } from '@shared/domain/session'; import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar'; @@ -8,6 +9,7 @@ export type SessionEventKind = | 'status' | 'message-delta' | 'message-complete' + | 'message-reclassified' | 'agent-activity' | 'run-updated' | 'error' @@ -27,6 +29,7 @@ export interface SessionEventRecord { occurredAt: string; status?: 'idle' | 'running' | 'error'; messageId?: string; + messageKind?: ChatMessageKind; authorName?: string; contentDelta?: string; content?: string; diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 3e1b0ac..2585eb0 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -18,6 +18,7 @@ import type { ChatMessageAttachment } from '@shared/domain/attachment'; import type { InteractionMode } from '@shared/contracts/sidecar'; export type ChatRole = 'system' | 'user' | 'assistant'; +export type ChatMessageKind = 'response' | 'thinking'; export type SessionStatus = 'idle' | 'running' | 'error'; export type SessionTitleSource = 'auto' | 'manual'; export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend'; @@ -33,6 +34,7 @@ export interface ChatMessageRecord { authorName: string; content: string; createdAt: string; + messageKind?: ChatMessageKind; isPinned?: boolean; pending?: boolean; attachments?: ChatMessageAttachment[];