mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 12:53:59 +02:00
feat: surface workflow diagnostics
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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 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
|
- **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
|
- **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,19 @@ public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
|
|||||||
public string? AgentName { get; init; }
|
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 sealed class SessionsListedEventDto : SidecarEventDto
|
||||||
{
|
{
|
||||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (TryCreateWorkflowDiagnosticEvent(command, evt, state, out WorkflowDiagnosticEventDto? diagnostic))
|
||||||
|
{
|
||||||
|
await onEvent(diagnostic).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (evt is AgentResponseUpdateEvent update)
|
if (evt is AgentResponseUpdateEvent update)
|
||||||
{
|
{
|
||||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
|
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)
|
private static bool IsHandoffFunctionName(string? candidate)
|
||||||
{
|
{
|
||||||
return !string.IsNullOrWhiteSpace(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]
|
[Fact]
|
||||||
public void RequiresToolCallApproval_HonorsAutoApprovedToolNames()
|
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]
|
[Fact]
|
||||||
public async Task RunTurnCommand_DeserializesInteractionMode()
|
public async Task RunTurnCommand_DeserializesInteractionMode()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2504,6 +2504,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
agentName: event.agentName,
|
agentName: event.agentName,
|
||||||
});
|
});
|
||||||
return;
|
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':
|
case 'assistant-usage':
|
||||||
this.emitSessionEvent({
|
this.emitSessionEvent({
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
AssistantUsageEvent,
|
AssistantUsageEvent,
|
||||||
AssistantIntentEvent,
|
AssistantIntentEvent,
|
||||||
ReasoningDeltaEvent,
|
ReasoningDeltaEvent,
|
||||||
|
WorkflowDiagnosticEvent,
|
||||||
} from '@shared/contracts/sidecar';
|
} from '@shared/contracts/sidecar';
|
||||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ export type TurnScopedEvent =
|
|||||||
| PendingMessagesModifiedEvent
|
| PendingMessagesModifiedEvent
|
||||||
| AssistantUsageEvent
|
| AssistantUsageEvent
|
||||||
| AssistantIntentEvent
|
| AssistantIntentEvent
|
||||||
| ReasoningDeltaEvent;
|
| ReasoningDeltaEvent
|
||||||
|
| WorkflowDiagnosticEvent;
|
||||||
|
|
||||||
export interface RunTurnPendingCommand {
|
export interface RunTurnPendingCommand {
|
||||||
kind: 'run-turn';
|
kind: 'run-turn';
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ export class SidecarClient {
|
|||||||
case 'session-usage':
|
case 'session-usage':
|
||||||
case 'session-compaction':
|
case 'session-compaction':
|
||||||
case 'pending-messages-modified':
|
case 'pending-messages-modified':
|
||||||
|
case 'workflow-diagnostic':
|
||||||
case 'assistant-usage':
|
case 'assistant-usage':
|
||||||
case 'assistant-intent':
|
case 'assistant-intent':
|
||||||
case 'reasoning-delta':
|
case 'reasoning-delta':
|
||||||
|
|||||||
@@ -384,6 +384,28 @@ export interface PendingMessagesModifiedEvent {
|
|||||||
agentName?: string;
|
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 {
|
export interface CopilotSessionInfo {
|
||||||
copilotSessionId: string;
|
copilotSessionId: string;
|
||||||
managedByAryx: boolean;
|
managedByAryx: boolean;
|
||||||
@@ -563,6 +585,7 @@ export type SidecarEvent =
|
|||||||
| SessionUsageEvent
|
| SessionUsageEvent
|
||||||
| SessionCompactionEvent
|
| SessionCompactionEvent
|
||||||
| PendingMessagesModifiedEvent
|
| PendingMessagesModifiedEvent
|
||||||
|
| WorkflowDiagnosticEvent
|
||||||
| ApprovalRequestedEvent
|
| ApprovalRequestedEvent
|
||||||
| UserInputRequestedEvent
|
| UserInputRequestedEvent
|
||||||
| McpOauthRequiredEvent
|
| McpOauthRequiredEvent
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
import type { ChatMessageKind } from '@shared/domain/session';
|
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';
|
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||||
|
|
||||||
@@ -19,7 +24,8 @@ export type SessionEventKind =
|
|||||||
| 'session-usage'
|
| 'session-usage'
|
||||||
| 'session-compaction'
|
| 'session-compaction'
|
||||||
| 'pending-messages-modified'
|
| 'pending-messages-modified'
|
||||||
| 'assistant-usage';
|
| 'assistant-usage'
|
||||||
|
| 'workflow-diagnostic';
|
||||||
|
|
||||||
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
|
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
|
||||||
|
|
||||||
@@ -86,4 +92,12 @@ export interface SessionEventRecord {
|
|||||||
usageDuration?: number;
|
usageDuration?: number;
|
||||||
usageTotalNanoAiu?: number;
|
usageTotalNanoAiu?: number;
|
||||||
usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
|
usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
|
||||||
|
|
||||||
|
// Workflow diagnostic fields
|
||||||
|
diagnosticSeverity?: WorkflowDiagnosticSeverity;
|
||||||
|
diagnosticKind?: WorkflowDiagnosticKind;
|
||||||
|
diagnosticMessage?: string;
|
||||||
|
executorId?: string;
|
||||||
|
subworkflowId?: string;
|
||||||
|
exceptionType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
};
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
|
|
||||||
import { describe, expect, mock, test } from 'bun:test';
|
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 {
|
class FakeReadableStream extends EventEmitter {
|
||||||
setEncoding(_encoding: BufferEncoding): void {}
|
setEncoding(_encoding: BufferEncoding): void {}
|
||||||
@@ -142,4 +142,100 @@ describe('SidecarClient', () => {
|
|||||||
spawnedProcesses[1]!.completeExit();
|
spawnedProcesses[1]!.completeExit();
|
||||||
await finalDispose;
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user