From e956f8ea6c4b083566aff1cbc3d0dbed9bfd14a9 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 23:55:25 +0200 Subject: [PATCH] feat: emit tool-call file previews Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 2 + .../Contracts/ProtocolModels.cs | 9 ++ .../Services/CopilotApprovalCoordinator.cs | 75 ++++++++++ .../Services/CopilotWorkflowRunner.cs | 1 + .../WorkflowRequestInfoInterpreter.cs | 1 + .../CopilotWorkflowRunnerTests.cs | 64 +++++++++ src/main/AryxAppService.ts | 4 + src/shared/contracts/sidecar.ts | 8 ++ src/shared/domain/event.ts | 4 +- src/shared/domain/runTimeline.ts | 133 +++++++++++++++++- tests/shared/runTimeline.test.ts | 51 +++++++ 11 files changed, 346 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 707e0f1..32d9614 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -220,6 +220,8 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace. +Tool-call activity records can also be enriched with a stable `toolCallId` and aggregated file-change preview payloads (`path`, unified diff, and optional new-file contents). The sidecar derives those previews from Copilot SDK write permission requests, and the main process merges repeated write events by `toolCallId` into the persisted run timeline so future UI surfaces can render file previews without reinterpreting approval payloads. + The same boundary also supports server-scoped sidecar commands that do not require a live Copilot session. The new `get-quota` command uses the SDK's `account.getQuota` RPC to fetch account quota snapshots on demand, then returns them as a `quota-result` protocol event followed by the usual `command-complete` sentinel. For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy. diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index ff8a313..e38d1bb 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -340,6 +340,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto public string? SourceAgentId { get; init; } public string? SourceAgentName { get; init; } public string? ToolName { get; init; } + public string? ToolCallId { get; init; } + public IReadOnlyList? FileChanges { get; init; } } public sealed class SubagentEventDto : SidecarEventDto @@ -503,6 +505,13 @@ public sealed class PermissionDetailDto public string? HookMessage { get; init; } } +public sealed class ToolCallFileChangeDto +{ + public string Path { get; init; } = string.Empty; + public string? Diff { get; init; } + public string? NewFileContents { get; init; } +} + public sealed class ApprovalRequestedEventDto : SidecarEventDto { public string SessionId { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs index decf91b..aa6e0d3 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs @@ -19,6 +19,7 @@ internal sealed class CopilotApprovalCoordinator private const string MemoryPermissionKind = "memory"; private const string CustomToolPermissionKind = "custom-tool"; private const string HookPermissionKind = "hook"; + private const string ToolCallingActivityType = "tool-calling"; private readonly ConcurrentDictionary _pendingApprovals = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _requestApprovedTools = new(StringComparer.Ordinal); @@ -54,11 +55,40 @@ internal sealed class CopilotApprovalCoordinator IReadOnlyDictionary toolNamesByCallId, Func onApproval, CancellationToken cancellationToken) + { + return await RequestApprovalAsync( + command, + agent, + request, + invocation, + toolNamesByCallId, + onActivity: null, + onApproval, + cancellationToken) + .ConfigureAwait(false); + } + + public async Task RequestApprovalAsync( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + PermissionRequest request, + PermissionInvocation invocation, + IReadOnlyDictionary toolNamesByCallId, + Func? onActivity, + Func onApproval, + CancellationToken cancellationToken) { string? toolName = ResolveApprovalToolName(request, toolNamesByCallId); string? autoApprovedToolName = ResolveAutoApprovedToolName(request); string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request); string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName); + + AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName); + if (fileChangeActivity is not null && onActivity is not null) + { + await onActivity(fileChangeActivity).ConfigureAwait(false); + } + if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey) || !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey)) { @@ -154,6 +184,46 @@ internal sealed class CopilotApprovalCoordinator }; } + internal static AgentActivityEventDto? BuildToolCallFileChangeActivity( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + PermissionRequest request, + string? toolName) + { + if (request is not PermissionRequestWrite write) + { + return null; + } + + string? filePath = NormalizeOptionalString(write.FileName); + if (filePath is null) + { + return null; + } + + string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name; + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = command.RequestId, + SessionId = command.SessionId, + ActivityType = ToolCallingActivityType, + AgentId = NormalizeOptionalString(agent.Id), + AgentName = NormalizeOptionalString(agentName), + ToolName = NormalizeOptionalString(toolName), + ToolCallId = NormalizeOptionalString(write.ToolCallId), + FileChanges = + [ + new ToolCallFileChangeDto + { + Path = filePath, + Diff = NormalizeOptionalPreviewText(write.Diff), + NewFileContents = NormalizeOptionalPreviewText(write.NewFileContents), + }, + ], + }; + } + internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -495,6 +565,11 @@ internal sealed class CopilotApprovalCoordinator return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } + private static string? NormalizeOptionalPreviewText(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value; + } + private static IReadOnlyList? NormalizeOptionalStringList(IEnumerable values) { List normalized = values diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index 16affc7..cec603b 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -50,6 +50,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner request, invocation, state.ToolNamesByCallId, + activity => EmitActivityAsync(command, state, activity, onEvent), onApproval, runCancellation.Token), (agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync( diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index cb8fbcb..5c55ab3 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -74,6 +74,7 @@ internal static class WorkflowRequestInfoInterpreter AgentId = activeAgent.AgentId, AgentName = activeAgent.AgentName, ToolName = tool.ToolName, + ToolCallId = tool.ToolCallId, }; } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 89d23ed..4c53e9a 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -1368,6 +1368,70 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal(PermissionRequestResultKind.Approved, result.Kind); } + [Fact] + public async Task RequestApprovalAsync_EmitsFileChangeActivityForWriteRequests() + { + CopilotApprovalCoordinator coordinator = new(); + AgentActivityEventDto? observedActivity = null; + ApprovalRequestedEventDto? observedApproval = null; + RunTurnCommandDto command = CreateApprovalCommand(); + + Task pending = coordinator.RequestApprovalAsync( + command, + command.Pattern.Agents[0], + new PermissionRequestWrite + { + Kind = "write", + ToolCallId = "tool-call-write-1", + Intention = "Update the README", + FileName = "README.md", + Diff = "@@ -1 +1 @@", + NewFileContents = "# Aryx\n", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal) + { + ["tool-call-write-1"] = "apply_patch", + }, + activity => + { + observedActivity = activity; + return Task.CompletedTask; + }, + approval => + { + observedApproval = approval; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(pending.IsCompleted); + Assert.NotNull(observedActivity); + Assert.NotNull(observedApproval); + Assert.Equal("tool-calling", observedActivity!.ActivityType); + Assert.Equal("apply_patch", observedActivity.ToolName); + Assert.Equal("tool-call-write-1", observedActivity.ToolCallId); + + ToolCallFileChangeDto preview = Assert.Single(observedActivity.FileChanges!); + Assert.Equal("README.md", preview.Path); + Assert.Equal("@@ -1 +1 @@", preview.Diff); + Assert.Equal("# Aryx\n", preview.NewFileContents); + + await coordinator.ResolveApprovalAsync( + new ResolveApprovalCommandDto + { + ApprovalId = observedApproval!.ApprovalId, + Decision = "approved", + }, + CancellationToken.None); + + PermissionRequestResult result = await pending; + Assert.Equal(PermissionRequestResultKind.Approved, result.Kind); + } + [Fact] public async Task RequestApprovalAsync_AutoApprovesToolsThatDoNotRequireApproval() { diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 7357e9a..c786017 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -1735,6 +1735,8 @@ export class AryxAppService extends EventEmitter { sourceAgentId: event.sourceAgentId, sourceAgentName: event.sourceAgentName, toolName: event.toolName, + toolCallId: event.toolCallId, + fileChanges: event.fileChanges, })); } if (nextRun) { @@ -1753,6 +1755,8 @@ export class AryxAppService extends EventEmitter { sourceAgentId: event.sourceAgentId, sourceAgentName: event.sourceAgentName, toolName: event.toolName, + toolCallId: event.toolCallId, + fileChanges: event.fileChanges, }); } diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index fd6fce9..0ec86f3 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -241,6 +241,12 @@ export interface TurnCompleteEvent { export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; +export interface ToolCallFileChangePreview { + path: string; + diff?: string; + newFileContents?: string; +} + export interface AgentActivityEvent { type: 'agent-activity'; requestId: string; @@ -251,6 +257,8 @@ export interface AgentActivityEvent { sourceAgentId?: string; sourceAgentName?: string; toolName?: string; + toolCallId?: string; + fileChanges?: ToolCallFileChangePreview[]; } export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected'; diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 802fb00..fec5783 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -1,6 +1,6 @@ import type { SessionRunRecord } from '@shared/domain/runTimeline'; -import type { QuotaSnapshot } from '@shared/contracts/sidecar'; +import type { QuotaSnapshot, ToolCallFileChangePreview } from '@shared/contracts/sidecar'; export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; @@ -36,6 +36,8 @@ export interface SessionEventRecord { sourceAgentId?: string; sourceAgentName?: string; toolName?: string; + toolCallId?: string; + fileChanges?: ToolCallFileChangePreview[]; run?: SessionRunRecord; error?: string; diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts index 75a745c..4abbd35 100644 --- a/src/shared/domain/runTimeline.ts +++ b/src/shared/domain/runTimeline.ts @@ -3,6 +3,7 @@ import type { ApprovalDecision, PendingApprovalRecord, } from '@shared/domain/approval'; +import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; import type { ProjectRecord } from '@shared/domain/project'; import { createId } from '@shared/utils/ids'; @@ -41,6 +42,8 @@ export interface RunTimelineEventRecord { targetAgentId?: string; targetAgentName?: string; toolName?: string; + toolCallId?: string; + fileChanges?: ToolCallFileChangePreview[]; approvalId?: string; approvalKind?: ApprovalCheckpointKind; approvalTitle?: string; @@ -86,6 +89,8 @@ export interface AppendRunActivityEventInput { sourceAgentId?: string; sourceAgentName?: string; toolName?: string; + toolCallId?: string; + fileChanges?: ToolCallFileChangePreview[]; } export interface UpsertRunMessageEventInput { @@ -113,6 +118,87 @@ function normalizeOptionalString(value: string | undefined): string | undefined return trimmed ? trimmed : undefined; } +function normalizeOptionalPreviewText(value: string | undefined): string | undefined { + return value?.trim() ? value : undefined; +} + +function normalizeToolCallFileChange( + change: ToolCallFileChangePreview, +): ToolCallFileChangePreview | undefined { + const path = normalizeOptionalString(change.path); + if (!path) { + return undefined; + } + + const diff = normalizeOptionalPreviewText(change.diff); + const newFileContents = normalizeOptionalPreviewText(change.newFileContents); + return { + path, + diff, + newFileContents, + }; +} + +function mergeToolCallFileChange( + existing: ToolCallFileChangePreview, + incoming: ToolCallFileChangePreview, +): ToolCallFileChangePreview { + return { + path: incoming.path, + diff: incoming.diff ?? existing.diff, + newFileContents: incoming.newFileContents ?? existing.newFileContents, + }; +} + +function normalizeToolCallFileChanges( + changes: readonly ToolCallFileChangePreview[] | undefined, +): ToolCallFileChangePreview[] | undefined { + if (!changes || changes.length === 0) { + return undefined; + } + + const normalized = new Map(); + for (const change of changes) { + const nextChange = normalizeToolCallFileChange(change); + if (!nextChange) { + continue; + } + + const previous = normalized.get(nextChange.path); + normalized.set( + nextChange.path, + previous ? mergeToolCallFileChange(previous, nextChange) : nextChange, + ); + } + + return normalized.size > 0 ? [...normalized.values()] : undefined; +} + +function mergeToolCallFileChanges( + existing: readonly ToolCallFileChangePreview[] | undefined, + incoming: readonly ToolCallFileChangePreview[] | undefined, +): ToolCallFileChangePreview[] | undefined { + const normalizedExisting = normalizeToolCallFileChanges(existing); + const normalizedIncoming = normalizeToolCallFileChanges(incoming); + if (!normalizedExisting) { + return normalizedIncoming; + } + + if (!normalizedIncoming) { + return normalizedExisting; + } + + const merged = new Map( + normalizedExisting.map((change) => [change.path, change] satisfies [string, ToolCallFileChangePreview]), + ); + for (const change of normalizedIncoming) { + const previous = merged.get(change.path); + merged.set(change.path, previous ? mergeToolCallFileChange(previous, change) : change); + } + + return [...merged.values()]; +} + function normalizeRunTimelineAgent( agent: RunTimelineAgentRecord, ): RunTimelineAgentRecord | undefined { @@ -153,6 +239,8 @@ function normalizeRunTimelineEvent( targetAgentId: normalizeOptionalString(event.targetAgentId), targetAgentName: normalizeOptionalString(event.targetAgentName), toolName: normalizeOptionalString(event.toolName), + toolCallId: normalizeOptionalString(event.toolCallId), + fileChanges: normalizeToolCallFileChanges(event.fileChanges), approvalId: normalizeOptionalString(event.approvalId), approvalKind: event.approvalKind, approvalTitle: normalizeOptionalString(event.approvalTitle), @@ -217,6 +305,28 @@ function appendRunTimelineEvent( }; } +function upsertRunTimelineEventAt( + run: SessionRunRecord, + eventIndex: number, + event: RunTimelineEventRecord, +): SessionRunRecord { + if (eventIndex < 0 || eventIndex >= run.events.length) { + return appendRunTimelineEvent(run, event); + } + + const nextEvent = normalizeRunTimelineEvent(event); + if (!nextEvent) { + return run; + } + + const nextEvents = run.events.slice(); + nextEvents[eventIndex] = nextEvent; + return { + ...run, + events: nextEvents, + }; +} + function settleOpenMessageEvents( run: SessionRunRecord, status: Extract, @@ -417,13 +527,26 @@ export function appendRunActivityEvent( } case 'tool-calling': { const agent = resolveRunTimelineAgent(run, input.agentId, input.agentName); - return appendRunTimelineEvent(run, { + const toolCallId = normalizeOptionalString(input.toolCallId); + const existingIndex = toolCallId + ? run.events.findIndex((event) => event.kind === 'tool-call' && event.toolCallId === toolCallId) + : -1; + const existingEvent = existingIndex >= 0 ? run.events[existingIndex] : undefined; + const nextEvent: RunTimelineEventRecord = { + id: existingEvent?.id ?? createId('run-event'), kind: 'tool-call', - occurredAt: input.occurredAt, + occurredAt: existingEvent?.occurredAt ?? input.occurredAt, + updatedAt: existingEvent ? input.occurredAt : undefined, status: 'completed', - ...agent, - toolName: normalizeOptionalString(input.toolName), - }); + agentId: agent.agentId ?? existingEvent?.agentId, + agentName: agent.agentName ?? existingEvent?.agentName, + toolName: normalizeOptionalString(input.toolName) ?? existingEvent?.toolName, + toolCallId, + fileChanges: mergeToolCallFileChanges(existingEvent?.fileChanges, input.fileChanges), + }; + return existingIndex >= 0 + ? upsertRunTimelineEventAt(run, existingIndex, nextEvent) + : appendRunTimelineEvent(run, nextEvent); } case 'handoff': { const sourceAgent = resolveRunTimelineAgent(run, input.sourceAgentId, input.sourceAgentName); diff --git a/tests/shared/runTimeline.test.ts b/tests/shared/runTimeline.test.ts index 4e7e8a9..617840b 100644 --- a/tests/shared/runTimeline.test.ts +++ b/tests/shared/runTimeline.test.ts @@ -167,6 +167,57 @@ describe('run timeline helpers', () => { }); }); + test('merges file change previews into a single tool-call event by toolCallId', () => { + const baseRun = createSessionRunRecord({ + requestId: 'turn-1', + project: createProject(), + workspaceKind: 'project', + pattern: createPattern(), + triggerMessageId: 'msg-user-1', + startedAt: '2026-03-23T00:00:01.000Z', + }); + + const startedRun = appendRunActivityEvent(baseRun, { + activityType: 'tool-calling', + occurredAt: '2026-03-23T00:00:02.000Z', + agentId: 'agent-writer', + toolName: 'apply_patch', + toolCallId: 'tool-call-1', + }); + + const firstPreviewRun = appendRunActivityEvent(startedRun, { + activityType: 'tool-calling', + occurredAt: '2026-03-23T00:00:03.000Z', + agentId: 'agent-writer', + toolName: 'apply_patch', + toolCallId: 'tool-call-1', + fileChanges: [{ path: 'src\\alpha.ts', diff: '@@ -1 +1 @@' }], + }); + + const mergedRun = appendRunActivityEvent(firstPreviewRun, { + activityType: 'tool-calling', + occurredAt: '2026-03-23T00:00:04.000Z', + agentId: 'agent-writer', + toolCallId: 'tool-call-1', + fileChanges: [{ path: 'src\\beta.ts', newFileContents: 'export const beta = true;\n' }], + }); + + const toolCallEvents = mergedRun.events.filter((event) => event.kind === 'tool-call'); + expect(toolCallEvents).toHaveLength(1); + expect(toolCallEvents[0]).toMatchObject({ + agentId: 'agent-writer', + agentName: 'Writer', + toolName: 'apply_patch', + toolCallId: 'tool-call-1', + occurredAt: '2026-03-23T00:00:02.000Z', + updatedAt: '2026-03-23T00:00:04.000Z', + }); + expect(toolCallEvents[0].fileChanges).toEqual([ + { path: 'src\\alpha.ts', diff: '@@ -1 +1 @@' }, + { path: 'src\\beta.ts', newFileContents: 'export const beta = true;\n' }, + ]); + }); + test('normalizes missing run collections to an empty array', () => { expect(normalizeSessionRunRecords(undefined)).toEqual([]); });