From 11b36827f5df2e426bae23f8c20975e25ae5a89e Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 1 Apr 2026 18:50:56 +0200 Subject: [PATCH] feat: surface workflow diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 1 + .../Contracts/ProtocolModels.cs | 13 ++ .../Services/CopilotWorkflowRunner.cs | 94 +++++++++++++++ .../CopilotWorkflowRunnerTests.cs | 111 ++++++++++++++++++ .../SidecarProtocolHostTests.cs | 71 +++++++++++ src/main/AryxAppService.ts | 15 +++ src/main/sidecar/runTurnPending.ts | 4 +- src/main/sidecar/sidecarProcess.ts | 1 + src/shared/contracts/sidecar.ts | 23 ++++ src/shared/domain/event.ts | 18 ++- .../appServiceWorkflowDiagnostics.test.ts | 82 +++++++++++++ tests/main/sidecarProcess.test.ts | 98 +++++++++++++++- 12 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 tests/main/appServiceWorkflowDiagnostics.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 38af4c7..e4b1074 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -224,6 +224,7 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt - **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming - **Session usage events**: current token count and context-window limit from `session.usage_info` for context-bar rendering - **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue +- **Workflow diagnostic events**: normalized warnings and errors from Agent Framework (`WorkflowWarningEvent`, `WorkflowErrorEvent`, `ExecutorFailedEvent`) with optional executor or subworkflow metadata for richer debugging surfaces 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. diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index ec5a6a4..b7acb1b 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -488,6 +488,19 @@ public sealed class PendingMessagesModifiedEventDto : SidecarEventDto public string? AgentName { get; init; } } +public sealed class WorkflowDiagnosticEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string Severity { get; init; } = string.Empty; + public string DiagnosticKind { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? AgentName { get; init; } + public string? ExecutorId { get; init; } + public string? SubworkflowId { get; init; } + public string? ExceptionType { get; init; } +} + public sealed class SessionsListedEventDto : SidecarEventDto { public IReadOnlyList Sessions { get; init; } = []; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index cec603b..441697a 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -211,6 +211,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return false; } + if (TryCreateWorkflowDiagnosticEvent(command, evt, state, out WorkflowDiagnosticEventDto? diagnostic)) + { + await onEvent(diagnostic).ConfigureAwait(false); + return false; + } + if (evt is AgentResponseUpdateEvent update) { await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false); @@ -341,6 +347,94 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } } + private static bool TryCreateWorkflowDiagnosticEvent( + RunTurnCommandDto command, + WorkflowEvent evt, + CopilotTurnExecutionState state, + out WorkflowDiagnosticEventDto diagnostic) + { + diagnostic = default!; + + switch (evt) + { + case ExecutorFailedEvent executorFailed: + { + AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity( + command.Pattern, + executorFailed.ExecutorId, + state.ActiveAgent, + out AgentIdentity resolvedAgent) + ? resolvedAgent + : null; + Exception? exception = executorFailed.Data; + diagnostic = new WorkflowDiagnosticEventDto + { + Type = "workflow-diagnostic", + RequestId = command.RequestId, + SessionId = command.SessionId, + Severity = "error", + DiagnosticKind = "executor-failed", + Message = ResolveDiagnosticMessage(exception, "Executor failed."), + AgentId = agent?.AgentId, + AgentName = agent?.AgentName, + ExecutorId = executorFailed.ExecutorId, + ExceptionType = exception?.GetBaseException().GetType().Name, + }; + return true; + } + case WorkflowWarningEvent workflowWarning: + diagnostic = new WorkflowDiagnosticEventDto + { + Type = "workflow-diagnostic", + RequestId = command.RequestId, + SessionId = command.SessionId, + Severity = "warning", + DiagnosticKind = workflowWarning is SubworkflowWarningEvent + ? "subworkflow-warning" + : "workflow-warning", + Message = ResolveDiagnosticMessage(workflowWarning.Data as string, "Workflow warning."), + SubworkflowId = workflowWarning is SubworkflowWarningEvent subworkflowWarning + ? subworkflowWarning.SubWorkflowId + : null, + }; + return true; + case WorkflowErrorEvent workflowError: + { + Exception? exception = workflowError.Exception; + diagnostic = new WorkflowDiagnosticEventDto + { + Type = "workflow-diagnostic", + RequestId = command.RequestId, + SessionId = command.SessionId, + Severity = "error", + DiagnosticKind = workflowError is SubworkflowErrorEvent + ? "subworkflow-error" + : "workflow-error", + Message = ResolveDiagnosticMessage(exception, "Workflow failed."), + SubworkflowId = workflowError is SubworkflowErrorEvent subworkflowError + ? subworkflowError.SubworkflowId + : null, + ExceptionType = exception?.GetBaseException().GetType().Name, + }; + return true; + } + default: + return false; + } + } + + private static string ResolveDiagnosticMessage(Exception? exception, string fallback) + { + return ResolveDiagnosticMessage( + exception?.GetBaseException().Message, + fallback); + } + + private static string ResolveDiagnosticMessage(string? message, string fallback) + { + return string.IsNullOrWhiteSpace(message) ? fallback : message; + } + private static bool IsHandoffFunctionName(string? candidate) { return !string.IsNullOrWhiteSpace(candidate) diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 773b8e0..88e51d7 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -798,6 +798,117 @@ public sealed class CopilotWorkflowRunnerTests }); } + [Fact] + public async Task HandleWorkflowEventAsync_EmitsExecutorFailedDiagnostic() + { + RunTurnCommandDto command = CreateApprovalCommand(); + CopilotTurnExecutionState state = new(command); + List diagnostics = []; + + MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( + "HandleWorkflowEventAsync", + BindingFlags.NonPublic | BindingFlags.Static)!; + Task handleTask = (Task)handleWorkflowEvent.Invoke( + null, + [ + command, + new ExecutorFailedEvent("agent-1", new InvalidOperationException("Tool crashed.")), + Array.Empty(), + state, + (Func)(_ => Task.CompletedTask), + (Func)(sidecarEvent => + { + diagnostics.Add(Assert.IsType(sidecarEvent)); + return Task.CompletedTask; + }), + ])!; + + bool shouldEndTurn = await handleTask; + + Assert.False(shouldEndTurn); + WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics); + Assert.Equal("workflow-diagnostic", diagnostic.Type); + Assert.Equal("error", diagnostic.Severity); + Assert.Equal("executor-failed", diagnostic.DiagnosticKind); + Assert.Equal("Tool crashed.", diagnostic.Message); + Assert.Equal("agent-1", diagnostic.AgentId); + Assert.Equal("Primary", diagnostic.AgentName); + Assert.Equal("agent-1", diagnostic.ExecutorId); + Assert.Equal("InvalidOperationException", diagnostic.ExceptionType); + } + + [Fact] + public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic() + { + RunTurnCommandDto command = CreateApprovalCommand(); + CopilotTurnExecutionState state = new(command); + List diagnostics = []; + + MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( + "HandleWorkflowEventAsync", + BindingFlags.NonPublic | BindingFlags.Static)!; + Task handleTask = (Task)handleWorkflowEvent.Invoke( + null, + [ + command, + new WorkflowWarningEvent("Token budget is nearly exhausted."), + Array.Empty(), + state, + (Func)(_ => Task.CompletedTask), + (Func)(sidecarEvent => + { + diagnostics.Add(Assert.IsType(sidecarEvent)); + return Task.CompletedTask; + }), + ])!; + + bool shouldEndTurn = await handleTask; + + Assert.False(shouldEndTurn); + WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics); + Assert.Equal("warning", diagnostic.Severity); + Assert.Equal("workflow-warning", diagnostic.DiagnosticKind); + Assert.Equal("Token budget is nearly exhausted.", diagnostic.Message); + Assert.Null(diagnostic.SubworkflowId); + Assert.Null(diagnostic.ExceptionType); + } + + [Fact] + public async Task HandleWorkflowEventAsync_EmitsSubworkflowErrorDiagnostic() + { + RunTurnCommandDto command = CreateApprovalCommand(); + CopilotTurnExecutionState state = new(command); + List diagnostics = []; + + MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( + "HandleWorkflowEventAsync", + BindingFlags.NonPublic | BindingFlags.Static)!; + Task handleTask = (Task)handleWorkflowEvent.Invoke( + null, + [ + command, + new SubworkflowErrorEvent("subworkflow-review", new InvalidOperationException("Reviewer agent failed.")), + Array.Empty(), + state, + (Func)(_ => Task.CompletedTask), + (Func)(sidecarEvent => + { + diagnostics.Add(Assert.IsType(sidecarEvent)); + return Task.CompletedTask; + }), + ])!; + + bool shouldEndTurn = await handleTask; + + Assert.False(shouldEndTurn); + WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics); + Assert.Equal("error", diagnostic.Severity); + Assert.Equal("subworkflow-error", diagnostic.DiagnosticKind); + Assert.Equal("Reviewer agent failed.", diagnostic.Message); + Assert.Equal("subworkflow-review", diagnostic.SubworkflowId); + Assert.Equal("InvalidOperationException", diagnostic.ExceptionType); + } + [Fact] public void RequiresToolCallApproval_HonorsAutoApprovedToolNames() { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index 021e5b3..d17467c 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -232,6 +232,77 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion() + { + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => + { + await onActivity(new WorkflowDiagnosticEventDto + { + Type = "workflow-diagnostic", + RequestId = command.RequestId, + SessionId = command.SessionId, + Severity = "error", + DiagnosticKind = "executor-failed", + Message = "Tool crashed.", + AgentId = "agent-1", + AgentName = "Primary", + ExecutorId = "agent-1", + ExceptionType = "InvalidOperationException", + }); + + return []; + })); + + IReadOnlyList events = await RunHostAsync( + new RunTurnCommandDto + { + Type = "run-turn", + RequestId = "turn-diagnostic", + SessionId = "session-1", + ProjectPath = "C:\\workspace\\project", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(name: "Primary"), + ], + }, + Messages = [], + }, + host); + + Assert.Collection( + events, + diagnosticEvent => + { + Assert.Equal("workflow-diagnostic", diagnosticEvent.GetProperty("type").GetString()); + Assert.Equal("turn-diagnostic", diagnosticEvent.GetProperty("requestId").GetString()); + Assert.Equal("session-1", diagnosticEvent.GetProperty("sessionId").GetString()); + Assert.Equal("error", diagnosticEvent.GetProperty("severity").GetString()); + Assert.Equal("executor-failed", diagnosticEvent.GetProperty("diagnosticKind").GetString()); + Assert.Equal("Tool crashed.", diagnosticEvent.GetProperty("message").GetString()); + Assert.Equal("agent-1", diagnosticEvent.GetProperty("executorId").GetString()); + }, + completionEvent => + { + Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString()); + Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); + }, + commandCompleteEvent => + { + Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); + Assert.Equal("turn-diagnostic", commandCompleteEvent.GetProperty("requestId").GetString()); + }); + } + [Fact] public async Task RunTurnCommand_DeserializesInteractionMode() { diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 1f97d85..16292c1 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -2504,6 +2504,21 @@ export class AryxAppService extends EventEmitter { agentName: event.agentName, }); return; + case 'workflow-diagnostic': + this.emitSessionEvent({ + sessionId, + kind: 'workflow-diagnostic', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + diagnosticSeverity: event.severity, + diagnosticKind: event.diagnosticKind, + diagnosticMessage: event.message, + executorId: event.executorId, + subworkflowId: event.subworkflowId, + exceptionType: event.exceptionType, + }); + return; case 'assistant-usage': this.emitSessionEvent({ sessionId, diff --git a/src/main/sidecar/runTurnPending.ts b/src/main/sidecar/runTurnPending.ts index dd0b613..564079f 100644 --- a/src/main/sidecar/runTurnPending.ts +++ b/src/main/sidecar/runTurnPending.ts @@ -15,6 +15,7 @@ import type { AssistantUsageEvent, AssistantIntentEvent, ReasoningDeltaEvent, + WorkflowDiagnosticEvent, } from '@shared/contracts/sidecar'; import type { ChatMessageRecord } from '@shared/domain/session'; @@ -27,7 +28,8 @@ export type TurnScopedEvent = | PendingMessagesModifiedEvent | AssistantUsageEvent | AssistantIntentEvent - | ReasoningDeltaEvent; + | ReasoningDeltaEvent + | WorkflowDiagnosticEvent; export interface RunTurnPendingCommand { kind: 'run-turn'; diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index dfb8b9f..8cb5da3 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -454,6 +454,7 @@ export class SidecarClient { case 'session-usage': case 'session-compaction': case 'pending-messages-modified': + case 'workflow-diagnostic': case 'assistant-usage': case 'assistant-intent': case 'reasoning-delta': diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 003ac36..b4117ea 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -384,6 +384,28 @@ export interface PendingMessagesModifiedEvent { agentName?: string; } +export type WorkflowDiagnosticSeverity = 'warning' | 'error'; +export type WorkflowDiagnosticKind = + | 'workflow-warning' + | 'workflow-error' + | 'executor-failed' + | 'subworkflow-warning' + | 'subworkflow-error'; + +export interface WorkflowDiagnosticEvent { + type: 'workflow-diagnostic'; + requestId: string; + sessionId: string; + severity: WorkflowDiagnosticSeverity; + diagnosticKind: WorkflowDiagnosticKind; + message: string; + agentId?: string; + agentName?: string; + executorId?: string; + subworkflowId?: string; + exceptionType?: string; +} + export interface CopilotSessionInfo { copilotSessionId: string; managedByAryx: boolean; @@ -563,6 +585,7 @@ export type SidecarEvent = | SessionUsageEvent | SessionCompactionEvent | PendingMessagesModifiedEvent + | WorkflowDiagnosticEvent | ApprovalRequestedEvent | UserInputRequestedEvent | McpOauthRequiredEvent diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index d2a037c..1bbefee 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -1,7 +1,12 @@ 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, + WorkflowDiagnosticKind, + WorkflowDiagnosticSeverity, +} from '@shared/contracts/sidecar'; export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; @@ -19,7 +24,8 @@ export type SessionEventKind = | 'session-usage' | 'session-compaction' | 'pending-messages-modified' - | 'assistant-usage'; + | 'assistant-usage' + | 'workflow-diagnostic'; export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected'; @@ -86,4 +92,12 @@ export interface SessionEventRecord { usageDuration?: number; usageTotalNanoAiu?: number; usageQuotaSnapshots?: Record; + + // Workflow diagnostic fields + diagnosticSeverity?: WorkflowDiagnosticSeverity; + diagnosticKind?: WorkflowDiagnosticKind; + diagnosticMessage?: string; + executorId?: string; + subworkflowId?: string; + exceptionType?: string; } diff --git a/tests/main/appServiceWorkflowDiagnostics.test.ts b/tests/main/appServiceWorkflowDiagnostics.test.ts new file mode 100644 index 0000000..cbdcced --- /dev/null +++ b/tests/main/appServiceWorkflowDiagnostics.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import type { SessionEventRecord } from '@shared/domain/event'; +import type { WorkflowDiagnosticEvent } from '@shared/contracts/sidecar'; + +mock.module('electron', () => { + const electronMock = { + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', + getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures', + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { AryxAppService } = await import('@main/AryxAppService'); + +describe('AryxAppService workflow diagnostics', () => { + test('maps turn-scoped workflow diagnostics to session events', async () => { + const service = new AryxAppService(); + const captured: SessionEventRecord[] = []; + const workflowDiagnostic: WorkflowDiagnosticEvent = { + type: 'workflow-diagnostic', + requestId: 'turn-1', + sessionId: 'session-1', + severity: 'error', + diagnosticKind: 'executor-failed', + message: 'Tool crashed.', + agentId: 'agent-1', + agentName: 'Primary', + executorId: 'agent-1', + exceptionType: 'InvalidOperationException', + }; + + const internals = service as unknown as { + emitSessionEvent: (event: SessionEventRecord) => void; + handleTurnScopedEvent: ( + workspace: unknown, + sessionId: string, + event: WorkflowDiagnosticEvent, + ) => void | Promise; + }; + internals.emitSessionEvent = (event) => { + captured.push(event); + }; + + await internals.handleTurnScopedEvent({}, 'session-1', workflowDiagnostic); + + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + sessionId: 'session-1', + kind: 'workflow-diagnostic', + agentId: 'agent-1', + agentName: 'Primary', + diagnosticSeverity: 'error', + diagnosticKind: 'executor-failed', + diagnosticMessage: 'Tool crashed.', + executorId: 'agent-1', + exceptionType: 'InvalidOperationException', + }); + expect(captured[0]?.occurredAt).toEqual(expect.any(String)); + }); +}); diff --git a/tests/main/sidecarProcess.test.ts b/tests/main/sidecarProcess.test.ts index b0bdd7d..5032902 100644 --- a/tests/main/sidecarProcess.test.ts +++ b/tests/main/sidecarProcess.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'; import { describe, expect, mock, test } from 'bun:test'; -import type { SidecarCapabilities } from '@shared/contracts/sidecar'; +import type { RunTurnCommand, SidecarCapabilities, WorkflowDiagnosticEvent } from '@shared/contracts/sidecar'; class FakeReadableStream extends EventEmitter { setEncoding(_encoding: BufferEncoding): void {} @@ -142,4 +142,100 @@ describe('SidecarClient', () => { spawnedProcesses[1]!.completeExit(); await finalDispose; }); + + test('routes workflow diagnostic events through the turn-scoped callback', async () => { + spawnedProcesses.length = 0; + const client = new SidecarClient(); + const diagnostics: WorkflowDiagnosticEvent[] = []; + const command: RunTurnCommand = { + type: 'run-turn', + requestId: 'turn-1', + sessionId: 'session-1', + projectPath: 'C:\\workspace\\project', + pattern: { + id: 'pattern-1', + name: 'Single Agent', + description: '', + mode: 'single', + availability: 'available', + maxIterations: 1, + agents: [ + { + id: 'agent-1', + name: 'Primary', + description: '', + instructions: 'Help with the request.', + model: 'gpt-5.4', + }, + ], + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-01T00:00:00.000Z', + }, + messages: [], + }; + + const turn = client.runTurn( + command, + async () => undefined, + async () => undefined, + async () => undefined, + async () => undefined, + async () => undefined, + async () => undefined, + async () => undefined, + async (event) => { + if (event.type === 'workflow-diagnostic') { + diagnostics.push(event); + } + }, + ); + + await Promise.resolve(); + expect(spawnedProcesses).toHaveLength(1); + + spawnedProcesses[0]!.emitStdout( + `${JSON.stringify({ + type: 'workflow-diagnostic', + requestId: command.requestId, + sessionId: command.sessionId, + severity: 'error', + diagnosticKind: 'executor-failed', + message: 'Tool crashed.', + agentId: 'agent-1', + agentName: 'Primary', + executorId: 'agent-1', + exceptionType: 'InvalidOperationException', + } satisfies WorkflowDiagnosticEvent)}\n`, + ); + spawnedProcesses[0]!.emitStdout( + `${JSON.stringify({ + type: 'turn-complete', + requestId: command.requestId, + sessionId: command.sessionId, + messages: [], + cancelled: false, + })}\n`, + ); + spawnedProcesses[0]!.emitStdout( + `${JSON.stringify({ + type: 'command-complete', + requestId: command.requestId, + })}\n`, + ); + + await expect(turn).resolves.toEqual([]); + expect(diagnostics).toEqual([ + expect.objectContaining({ + type: 'workflow-diagnostic', + severity: 'error', + diagnosticKind: 'executor-failed', + message: 'Tool crashed.', + executorId: 'agent-1', + }), + ]); + + const dispose = client.dispose(); + spawnedProcesses[0]!.completeExit(); + await dispose; + }); });