mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
feat: emit tool-call file previews
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<ToolCallFileChangeDto>? 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;
|
||||
|
||||
@@ -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<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
|
||||
@@ -54,11 +55,40 @@ internal sealed class CopilotApprovalCoordinator
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
toolNamesByCallId,
|
||||
onActivity: null,
|
||||
onApproval,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
Func<AgentActivityEventDto, Task>? onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> 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<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -74,6 +74,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
AgentId = activeAgent.AgentId,
|
||||
AgentName = activeAgent.AgentName,
|
||||
ToolName = tool.ToolName,
|
||||
ToolCallId = tool.ToolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PermissionRequestResult> 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<string, string>(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()
|
||||
{
|
||||
|
||||
@@ -1735,6 +1735,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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<AppServiceEvents> {
|
||||
sourceAgentId: event.sourceAgentId,
|
||||
sourceAgentName: event.sourceAgentName,
|
||||
toolName: event.toolName,
|
||||
toolCallId: event.toolCallId,
|
||||
fileChanges: event.fileChanges,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, ToolCallFileChangePreview>();
|
||||
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<RunTimelineEventStatus, 'completed' | 'error'>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user