feat: add thinking protocol events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-30 16:11:58 +01:00
co-authored by Copilot
parent 38dd358755
commit 56de8b7bd6
7 changed files with 346 additions and 1 deletions
+2
View File
@@ -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 - **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 - **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 - **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 - **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 - **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
@@ -331,6 +331,13 @@ public sealed class TurnCompleteEventDto : SidecarEventDto
public bool Cancelled { get; init; } 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 sealed class AgentActivityEventDto : SidecarEventDto
{ {
public string SessionId { get; init; } = string.Empty; public string SessionId { get; init; } = string.Empty;
@@ -376,6 +383,23 @@ public sealed class SkillInvokedEventDto : SidecarEventDto
public string? Description { get; init; } 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 sealed class HookLifecycleEventDto : SidecarEventDto
{ {
public string SessionId { get; init; } = string.Empty; public string SessionId { get; init; } = string.Empty;
@@ -9,11 +9,13 @@ internal sealed class CopilotTurnExecutionState
{ {
private readonly RunTurnCommandDto _command; private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new(); private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new(); private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex; private int _fallbackMessageIndex;
private string? _lastObservedMessageId;
public CopilotTurnExecutionState(RunTurnCommandDto command) public CopilotTurnExecutionState(RunTurnCommandDto command)
{ {
@@ -79,15 +81,34 @@ internal sealed class CopilotTurnExecutionState
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId): case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId); RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
QueueThinkingIfNeeded(agent); QueueThinkingIfNeeded(agent);
if (assistantMessage.Data?.ToolRequests is { Length: > 0 })
{
QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId);
}
break; break;
case ToolExecutionStartEvent toolExecutionStart case ToolExecutionStartEvent toolExecutionStart
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId) when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName): && !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim(); ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break; break;
case AssistantReasoningDeltaEvent: case AssistantIntentEvent intentEvent:
ActiveAgent = agent; ActiveAgent = agent;
QueueThinkingIfNeeded(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; break;
case SubagentStartedEvent started: case SubagentStartedEvent started:
ActiveAgent = agent; ActiveAgent = agent;
@@ -218,6 +239,23 @@ internal sealed class CopilotTurnExecutionState
{ {
ActiveAgent = agent; ActiveAgent = agent;
_observedAgentsByMessageId[messageId] = 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) 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( public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages, IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages) IReadOnlyList<ChatMessage> 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( private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent, AgentIdentity agent,
SkillInvokedData? data) SkillInvokedData? data)
@@ -83,6 +83,132 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("view", toolName); 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<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
Assert.Equal("thinking", thinking.ActivityType);
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
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<SidecarEventDto> pending = state.DrainPendingEvents();
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
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<MessageReclassifiedEventDto>());
}
[Fact] [Fact]
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity() public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
{ {
@@ -119,6 +245,70 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", thinking.AgentId); 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<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
Assert.Equal("thinking", thinking.ActivityType);
AssistantIntentEventDto intent = Assert.Single(pending.OfType<AssistantIntentEventDto>());
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<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
Assert.Equal("thinking", thinking.ActivityType);
ReasoningDeltaEventDto reasoning = Assert.Single(pending.OfType<ReasoningDeltaEventDto>());
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] [Fact]
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue() public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
{ {
+30
View File
@@ -239,6 +239,14 @@ export interface TurnCompleteEvent {
cancelled?: boolean; 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 type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
export interface ToolCallFileChangePreview { export interface ToolCallFileChangePreview {
@@ -297,6 +305,25 @@ export interface SkillInvokedEvent {
description?: string; 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 { export interface HookLifecycleEvent {
type: 'hook-lifecycle'; type: 'hook-lifecycle';
requestId: string; requestId: string;
@@ -526,9 +553,12 @@ export type SidecarEvent =
| PatternValidationEvent | PatternValidationEvent
| TurnDeltaEvent | TurnDeltaEvent
| TurnCompleteEvent | TurnCompleteEvent
| MessageReclassifiedEvent
| AgentActivityEvent | AgentActivityEvent
| SubagentEvent | SubagentEvent
| SkillInvokedEvent | SkillInvokedEvent
| AssistantIntentEvent
| ReasoningDeltaEvent
| HookLifecycleEvent | HookLifecycleEvent
| SessionUsageEvent | SessionUsageEvent
| SessionCompactionEvent | SessionCompactionEvent
+3
View File
@@ -1,4 +1,5 @@
import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { ChatMessageKind } from '@shared/domain/session';
import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar'; import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar';
@@ -8,6 +9,7 @@ export type SessionEventKind =
| 'status' | 'status'
| 'message-delta' | 'message-delta'
| 'message-complete' | 'message-complete'
| 'message-reclassified'
| 'agent-activity' | 'agent-activity'
| 'run-updated' | 'run-updated'
| 'error' | 'error'
@@ -27,6 +29,7 @@ export interface SessionEventRecord {
occurredAt: string; occurredAt: string;
status?: 'idle' | 'running' | 'error'; status?: 'idle' | 'running' | 'error';
messageId?: string; messageId?: string;
messageKind?: ChatMessageKind;
authorName?: string; authorName?: string;
contentDelta?: string; contentDelta?: string;
content?: string; content?: string;
+2
View File
@@ -18,6 +18,7 @@ import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { InteractionMode } from '@shared/contracts/sidecar'; import type { InteractionMode } from '@shared/contracts/sidecar';
export type ChatRole = 'system' | 'user' | 'assistant'; export type ChatRole = 'system' | 'user' | 'assistant';
export type ChatMessageKind = 'response' | 'thinking';
export type SessionStatus = 'idle' | 'running' | 'error'; export type SessionStatus = 'idle' | 'running' | 'error';
export type SessionTitleSource = 'auto' | 'manual'; export type SessionTitleSource = 'auto' | 'manual';
export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend'; export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend';
@@ -33,6 +34,7 @@ export interface ChatMessageRecord {
authorName: string; authorName: string;
content: string; content: string;
createdAt: string; createdAt: string;
messageKind?: ChatMessageKind;
isPinned?: boolean; isPinned?: boolean;
pending?: boolean; pending?: boolean;
attachments?: ChatMessageAttachment[]; attachments?: ChatMessageAttachment[];