feat: surface workflow diagnostics

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-01 18:50:56 +02:00
co-authored by Copilot
parent b434dd86b4
commit 11b36827f5
12 changed files with 527 additions and 4 deletions
@@ -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<CopilotSessionInfoDto> Sessions { get; init; } = [];
@@ -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)
@@ -798,6 +798,117 @@ public sealed class CopilotWorkflowRunnerTests
});
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsExecutorFailedDiagnostic()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
List<WorkflowDiagnosticEventDto> diagnostics = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorFailedEvent("agent-1", new InvalidOperationException("Tool crashed.")),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(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<WorkflowDiagnosticEventDto> diagnostics = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new WorkflowWarningEvent("Token budget is nearly exhausted."),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(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<WorkflowDiagnosticEventDto> diagnostics = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new SubworkflowErrorEvent("subworkflow-review", new InvalidOperationException("Reviewer agent failed.")),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(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()
{
@@ -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<JsonElement> 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()
{