mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-25 05:58:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc69f8bf04 | ||
|
|
6f7cf60aa9 | ||
|
|
423d45fa1b | ||
|
|
13bcc44f1a | ||
|
|
1ceb3d5669 | ||
|
|
1dd13588a0 | ||
|
|
235ddf7e56 | ||
|
|
d7004ec2a9 | ||
|
|
0aed6240b9 | ||
|
|
11b36827f5 |
@@ -224,6 +224,8 @@ 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
|
||||
- **Workflow checkpoint events**: emitted at Agent Framework superstep boundaries with workflow session ID, checkpoint ID, step number, and checkpoint-store path so the main process can prepare crash-recovery state
|
||||
|
||||
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.
|
||||
|
||||
@@ -235,6 +237,8 @@ For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook
|
||||
|
||||
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from repo-level instruction files and merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. The sidecar then folds those project instructions into the final SDK system message alongside the agent's own instructions and runtime guidance.
|
||||
|
||||
For handoff workflows, the sidecar now also enables Agent Framework JSON checkpointing backed by a per-turn filesystem store under local app data. Each saved checkpoint is surfaced to the main process, which pairs the durable Agent Framework checkpoint with an in-memory rollback snapshot of `session.messages` and the active run timeline events. If the sidecar child process exits unexpectedly during the same app lifetime, Aryx restores the latest snapshot, clears pending approval/user-input/MCP-auth state for that run, and retries the `run-turn` request once with `resumeFromCheckpoint`. Checkpoint directories are deleted after the turn completes, cancels, or fails. This recovery path is intentionally scoped to same-app sidecar restarts; full app-restart workflow rehydration would require durable rollback snapshots in addition to the Agent Framework checkpoint payloads.
|
||||
|
||||
## Security model
|
||||
|
||||
Security in this system is mostly about **desktop trust boundaries**.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aryx",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.21",
|
||||
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
|
||||
@@ -188,6 +188,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
public WorkflowCheckpointResumeDto? ResumeFromCheckpoint { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
|
||||
@@ -488,6 +489,28 @@ public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
|
||||
public string? AgentName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowCheckpointSavedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string WorkflowSessionId { get; init; } = string.Empty;
|
||||
public string CheckpointId { get; init; } = string.Empty;
|
||||
public string StorePath { get; init; } = string.Empty;
|
||||
public int StepNumber { 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; } = [];
|
||||
@@ -591,6 +614,13 @@ public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
|
||||
public string? RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowCheckpointResumeDto
|
||||
{
|
||||
public string WorkflowSessionId { get; init; } = string.Empty;
|
||||
public string CheckpointId { get; init; } = string.Empty;
|
||||
public string StorePath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
@@ -51,27 +51,7 @@ internal static class AgentInstructionComposer
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
|
||||
}
|
||||
|
||||
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
|
||||
}
|
||||
|
||||
string runtimeGuidance = agentIndex == 0
|
||||
? """
|
||||
You are the routing gate for this handoff workflow.
|
||||
Your job is to classify the request and hand it off to the most appropriate specialist as soon as you know who should own the substantive work.
|
||||
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
|
||||
Do not inspect files, call tools, draft the implementation, or produce the final user-facing answer yourself once a specialist is appropriate.
|
||||
Do not claim that you handed work off unless you actually executed the handoff.
|
||||
Only answer directly if the user is asking for pure triage or a minimal clarification that must happen before delegation.
|
||||
"""
|
||||
: """
|
||||
You are a specialist participating in a handoff workflow.
|
||||
Once the triage agent hands work to you, you own the substantive answer within your specialty and should carry it through.
|
||||
Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
@@ -104,6 +105,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
try
|
||||
{
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
{
|
||||
@@ -114,9 +116,19 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
break;
|
||||
|
||||
case AssistantMessageEvent assistantMessage:
|
||||
TrackToolRequestNames(toolNamesByCallId, assistantMessage.Data?.ToolRequests);
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(assistantMessage));
|
||||
break;
|
||||
|
||||
case ToolExecutionCompleteEvent toolExecutionComplete:
|
||||
AgentResponseUpdate? toolResultUpdate = ConvertToAgentResponseUpdate(toolExecutionComplete, toolNamesByCallId);
|
||||
if (toolResultUpdate is not null)
|
||||
{
|
||||
channel.Writer.TryWrite(toolResultUpdate);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case AssistantUsageEvent usageEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(usageEvent));
|
||||
break;
|
||||
@@ -232,16 +244,6 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only project handoff tool calls as FunctionCallContent for the Agent Framework.
|
||||
// Other tool calls (ask_user, MCP tools, etc.) are resolved by the Copilot SDK
|
||||
// internally and must not be surfaced, because AIAgentHostExecutor tracks every
|
||||
// FunctionCallContent as an outstanding request. An unmatched request prevents
|
||||
// the executor from emitting a TurnToken, which stalls group-chat advancement.
|
||||
if (!IsHandoffToolName(toolRequest.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
toolRequest.ToolCallId,
|
||||
toolRequest.Name,
|
||||
@@ -251,6 +253,26 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return contents;
|
||||
}
|
||||
|
||||
internal static FunctionResultContent? TryCreateToolResultContent(
|
||||
ToolExecutionCompleteEvent toolExecutionComplete,
|
||||
string? toolName = null)
|
||||
{
|
||||
// Regular Copilot tools need their result projected back into AF so the function call
|
||||
// remains part of workflow-visible history. Handoff tools are finalized separately by
|
||||
// HandoffAgentExecutor, which already injects its own "Transferred." result.
|
||||
string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId) || IsHandoffToolName(toolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string result = ResolveToolResultText(toolExecutionComplete.Data);
|
||||
return new FunctionResultContent(toolCallId, result)
|
||||
{
|
||||
RawRepresentation = toolExecutionComplete,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsHandoffToolName(string? name)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(name)
|
||||
@@ -441,6 +463,36 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate? ConvertToAgentResponseUpdate(
|
||||
ToolExecutionCompleteEvent toolExecutionComplete,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? toolName = null;
|
||||
if (toolNamesByCallId.TryRemove(toolCallId, out string? trackedToolName))
|
||||
{
|
||||
toolName = trackedToolName;
|
||||
}
|
||||
|
||||
FunctionResultContent? toolResult = TryCreateToolResultContent(toolExecutionComplete, toolName);
|
||||
if (toolResult is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Tool, [toolResult])
|
||||
{
|
||||
AgentId = Id,
|
||||
MessageId = toolCallId,
|
||||
CreatedAt = toolExecutionComplete.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent)
|
||||
{
|
||||
AIContent content = new()
|
||||
@@ -455,6 +507,45 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private static void TrackToolRequestNames(
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId,
|
||||
AssistantMessageDataToolRequestsItem[]? toolRequests)
|
||||
{
|
||||
if (toolRequests is not { Length: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests)
|
||||
{
|
||||
string? toolCallId = toolRequest.ToolCallId?.Trim();
|
||||
string? toolName = toolRequest.Name?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
toolNamesByCallId[toolCallId] = toolName;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveToolResultText(ToolExecutionCompleteData? toolExecutionCompleteData)
|
||||
{
|
||||
if (toolExecutionCompleteData is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (toolExecutionCompleteData.Success)
|
||||
{
|
||||
return toolExecutionCompleteData.Result?.Content
|
||||
?? toolExecutionCompleteData.Result?.DetailedContent
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
return toolExecutionCompleteData.Error?.Message ?? string.Empty;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? ParseToolArguments(object? arguments)
|
||||
{
|
||||
if (arguments is null)
|
||||
|
||||
@@ -189,9 +189,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
{
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)),
|
||||
"single" => BuildSequentialWorkflow(pattern),
|
||||
"sequential" => BuildSequentialWorkflow(pattern),
|
||||
"concurrent" => BuildConcurrentWorkflow(pattern),
|
||||
"handoff" => BuildHandoffWorkflow(pattern),
|
||||
"group-chat" => BuildGroupChatWorkflow(pattern),
|
||||
"magentic" => throw new NotSupportedException(
|
||||
@@ -222,8 +222,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
: pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId;
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0];
|
||||
|
||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(entryAgent);
|
||||
|
||||
foreach (PatternHandoffRoute route in topology.Routes)
|
||||
{
|
||||
@@ -250,20 +249,136 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||
{
|
||||
return new AIAgentHostOptions
|
||||
{
|
||||
// Aryx controls per-turn streaming with TurnToken(emitEvents: true), so keep this
|
||||
// null to preserve that behavior while making the host defaults explicit in code.
|
||||
EmitAgentUpdateEvents = null,
|
||||
// Aryx already projects streamed transcript state itself; enabling this would add
|
||||
// extra response events that need separate reconciliation first.
|
||||
EmitAgentResponseEvents = false,
|
||||
InterceptUserInputRequests = false,
|
||||
InterceptUnterminatedFunctionCalls = false,
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
}
|
||||
|
||||
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent)
|
||||
{
|
||||
return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
// Preserve normal tool-call history across handoffs while still hiding the
|
||||
// workflow's handoff plumbing. Make this explicit so AF default changes
|
||||
// cannot silently alter Aryx handoff behavior.
|
||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
}
|
||||
|
||||
private Workflow BuildSequentialWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
||||
List<ExecutorBinding> agentExecutors = agents
|
||||
.Select(CreateAgentExecutorBinding)
|
||||
.ToList();
|
||||
|
||||
ExecutorBinding previous = agentExecutors[0];
|
||||
WorkflowBuilder builder = new(previous);
|
||||
|
||||
foreach (ExecutorBinding next in agentExecutors.Skip(1))
|
||||
{
|
||||
builder.AddEdge(previous, next);
|
||||
previous = next;
|
||||
}
|
||||
|
||||
WorkflowOutputMessagesExecutor end = new();
|
||||
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
||||
|
||||
if (pattern.Name is not null)
|
||||
{
|
||||
builder = builder.WithName(pattern.Name);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildConcurrentWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
||||
ChatForwardingExecutor start = new("Start");
|
||||
WorkflowBuilder builder = new(start);
|
||||
|
||||
ExecutorBinding[] agentExecutors = agents
|
||||
.Select(CreateAgentExecutorBinding)
|
||||
.ToArray();
|
||||
ExecutorBinding[] accumulators = agentExecutors
|
||||
.Select(executor => CreateAggregateMessagesExecutorBinding($"Batcher/{executor.Id}"))
|
||||
.ToArray();
|
||||
|
||||
builder.AddFanOutEdge(start, agentExecutors);
|
||||
|
||||
for (int index = 0; index < agentExecutors.Length; index++)
|
||||
{
|
||||
builder.AddEdge(agentExecutors[index], accumulators[index]);
|
||||
}
|
||||
|
||||
Func<string, string, ValueTask<WorkflowConcurrentEndExecutor>> endFactory =
|
||||
(_, __) => new(new WorkflowConcurrentEndExecutor(agentExecutors.Length, AggregateConcurrentResults));
|
||||
ExecutorBinding end = endFactory.BindExecutor(WorkflowConcurrentEndExecutor.ExecutorId);
|
||||
|
||||
builder.AddFanInBarrierEdge(accumulators, end);
|
||||
builder = builder.WithOutputFrom(end);
|
||||
|
||||
if (pattern.Name is not null)
|
||||
{
|
||||
builder = builder.WithName(pattern.Name);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
|
||||
AIAgent[] agents = ResolveOrderedAgents(pattern).ToArray();
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(
|
||||
agent => agent,
|
||||
CreateAgentExecutorBinding);
|
||||
|
||||
return AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents =>
|
||||
new RoundRobinGroupChatManager(agents)
|
||||
{
|
||||
MaximumIterationCount = maximumIterations,
|
||||
})
|
||||
.AddParticipants(ResolveOrderedAgents(pattern).ToArray())
|
||||
.Build();
|
||||
Func<string, string, ValueTask<WorkflowRoundRobinGroupChatHost>> groupChatHostFactory =
|
||||
(id, _) => new(new WorkflowRoundRobinGroupChatHost(
|
||||
id,
|
||||
agents,
|
||||
agentMap,
|
||||
maximumIterations));
|
||||
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor("GroupChatHost");
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
foreach (ExecutorBinding participant in agentMap.Values)
|
||||
{
|
||||
builder
|
||||
.AddEdge(host, participant)
|
||||
.AddEdge(participant, host);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(host).Build();
|
||||
}
|
||||
|
||||
private static ExecutorBinding CreateAgentExecutorBinding(AIAgent agent)
|
||||
=> agent.BindAsExecutor(CreateAgentHostOptions());
|
||||
|
||||
private static ExecutorBinding CreateAggregateMessagesExecutorBinding(string id)
|
||||
{
|
||||
Func<string, string, ValueTask<WorkflowAggregateTurnMessagesExecutor>> factory =
|
||||
(_, __) => new(new WorkflowAggregateTurnMessagesExecutor(id));
|
||||
return factory.BindExecutor(id);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> AggregateConcurrentResults(IList<List<ChatMessage>> lists)
|
||||
=> [.. from list in lists where list.Count > 0 select list.Last()];
|
||||
|
||||
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
|
||||
{
|
||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
||||
|
||||
@@ -89,7 +89,16 @@ internal sealed class CopilotTurnExecutionState
|
||||
case ToolExecutionStartEvent toolExecutionStart
|
||||
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
||||
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
||||
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
|
||||
string toolCallId = toolExecutionStart.Data.ToolCallId.Trim();
|
||||
string toolName = toolExecutionStart.Data.ToolName.Trim();
|
||||
ToolNamesByCallId[toolCallId] = toolName;
|
||||
ActiveAgent = agent;
|
||||
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(agent, toolName, toolCallId);
|
||||
if (toolActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(toolActivity);
|
||||
}
|
||||
|
||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||
break;
|
||||
case AssistantIntentEvent intentEvent:
|
||||
@@ -278,6 +287,29 @@ internal sealed class CopilotTurnExecutionState
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateToolCallingActivity(
|
||||
AgentIdentity agent,
|
||||
string toolName,
|
||||
string toolCallId)
|
||||
{
|
||||
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "tool-calling",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||
{
|
||||
return new MessageReclassifiedEventDto
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
@@ -81,7 +83,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||
using FileSystemJsonCheckpointStore? checkpointStore = CreateCheckpointStore(command);
|
||||
CheckpointManager? checkpointManager = checkpointStore is not null
|
||||
? CheckpointManager.CreateJson(checkpointStore)
|
||||
: null;
|
||||
|
||||
await using StreamingRun run = await OpenWorkflowRunAsync(
|
||||
command,
|
||||
workflow,
|
||||
inputMessages,
|
||||
checkpointManager).ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
|
||||
@@ -120,6 +131,62 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
|
||||
{
|
||||
if (!ShouldEnableWorkflowCheckpointing(command))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DirectoryInfo checkpointDirectory = new(GetCheckpointStorePath(command));
|
||||
return new FileSystemJsonCheckpointStore(checkpointDirectory);
|
||||
}
|
||||
|
||||
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.ResumeFromCheckpoint?.StorePath))
|
||||
{
|
||||
return command.ResumeFromCheckpoint.StorePath;
|
||||
}
|
||||
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "Aryx", "workflow-checkpoints", command.SessionId, command.RequestId);
|
||||
}
|
||||
|
||||
private static ValueTask<StreamingRun> OpenWorkflowRunAsync(
|
||||
RunTurnCommandDto command,
|
||||
Workflow workflow,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CheckpointManager? checkpointManager)
|
||||
{
|
||||
if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint)
|
||||
{
|
||||
return InProcessExecution.ResumeStreamingAsync(
|
||||
workflow,
|
||||
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId),
|
||||
checkpointManager);
|
||||
}
|
||||
|
||||
if (checkpointManager is not null)
|
||||
{
|
||||
return InProcessExecution.RunStreamingAsync(
|
||||
workflow,
|
||||
inputMessages.ToList(),
|
||||
checkpointManager,
|
||||
sessionId: command.RequestId);
|
||||
}
|
||||
|
||||
return InProcessExecution.RunStreamingAsync(workflow, inputMessages.ToList());
|
||||
}
|
||||
|
||||
internal static void ConfigureHookLifecycleEventSuppression(
|
||||
CopilotTurnExecutionState state,
|
||||
CopilotAgentBundle bundle)
|
||||
@@ -211,6 +278,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryCreateWorkflowCheckpointSavedEvent(command, evt, out WorkflowCheckpointSavedEventDto? checkpointSaved))
|
||||
{
|
||||
await onEvent(checkpointSaved).ConfigureAwait(false);
|
||||
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);
|
||||
@@ -274,6 +353,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
updateAgent = resolvedUpdateAgent;
|
||||
authorName = resolvedUpdateAgent.AgentName;
|
||||
}
|
||||
else if (state.ActiveAgent is AgentIdentity activeAgent)
|
||||
{
|
||||
updateAgent = activeAgent;
|
||||
authorName = activeAgent.AgentName;
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Agent response update fell back to active agent {activeAgent.AgentName} ({activeAgent.AgentId}) for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? "<none>"}'.");
|
||||
}
|
||||
|
||||
if (updateAgent.HasValue)
|
||||
{
|
||||
@@ -341,6 +428,121 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryCreateWorkflowCheckpointSavedEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
out WorkflowCheckpointSavedEventDto checkpointSaved)
|
||||
{
|
||||
checkpointSaved = default!;
|
||||
|
||||
if (!ShouldEnableWorkflowCheckpointing(command)
|
||||
|| evt is not SuperStepCompletedEvent superStepCompleted
|
||||
|| superStepCompleted.CompletionInfo?.Checkpoint is not CheckpointInfo checkpoint)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
checkpointSaved = new WorkflowCheckpointSavedEventDto
|
||||
{
|
||||
Type = "workflow-checkpoint-saved",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
WorkflowSessionId = checkpoint.SessionId,
|
||||
CheckpointId = checkpoint.CheckpointId,
|
||||
StorePath = GetCheckpointStorePath(command),
|
||||
StepNumber = superStepCompleted.StepNumber,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -8,11 +8,15 @@ internal static class HandoffWorkflowGuidance
|
||||
{
|
||||
return """
|
||||
This workflow uses explicit handoffs to transfer ownership between agents.
|
||||
If you are acting as the routing or triage agent, classify the request and hand it off to the best specialist as soon as ownership is clear.
|
||||
If another agent should do the substantive work, perform an actual handoff instead of answering as though the handoff already happened.
|
||||
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
|
||||
Do not claim that you delegated unless you actually executed the handoff.
|
||||
The triage agent should route to the best specialist promptly once ownership is clear.
|
||||
In a specialist workflow, the triage agent should hand off before inspecting files, calling tools, or drafting the substantive implementation.
|
||||
If a specialist is appropriate, do not inspect files, call tools, draft the implementation, or produce the final user-facing answer before handing off.
|
||||
Only answer directly when the request is pure triage or a minimal clarification is required before delegation.
|
||||
Do not narrate a handoff in plain text without executing the handoff itself.
|
||||
If you receive work as a specialist, own the substantive answer within your specialty and carry it through.
|
||||
Do not push the work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
Specialists should complete the substantive work after handoff and only hand control back when the task needs re-routing, broader coordination, or is outside their specialty.
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class WorkflowOutputMessagesExecutor(ChatProtocolExecutorOptions? options = null)
|
||||
: ChatProtocolExecutor(ExecutorId, options, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "OutputMessages";
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> base.ConfigureProtocol(protocolBuilder)
|
||||
.YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> context.YieldOutputAsync(messages, cancellationToken);
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => default;
|
||||
}
|
||||
|
||||
internal sealed class WorkflowAggregateTurnMessagesExecutor(string id)
|
||||
: ChatProtocolExecutor(id, s_options, declareCrossRunShareable: true), IResettableExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new() { AutoSendTurnToken = false };
|
||||
|
||||
protected override ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
|
||||
}
|
||||
|
||||
internal sealed class WorkflowConcurrentEndExecutor : Executor, IResettableExecutor
|
||||
{
|
||||
public const string ExecutorId = "ConcurrentEnd";
|
||||
|
||||
private readonly int _expectedInputs;
|
||||
private readonly Func<IList<List<ChatMessage>>, List<ChatMessage>> _aggregator;
|
||||
private List<List<ChatMessage>> _allResults;
|
||||
private int _remaining;
|
||||
|
||||
public WorkflowConcurrentEndExecutor(
|
||||
int expectedInputs,
|
||||
Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
|
||||
: base(ExecutorId)
|
||||
{
|
||||
_expectedInputs = expectedInputs;
|
||||
_aggregator = aggregator;
|
||||
_allResults = new List<List<ChatMessage>>(expectedInputs);
|
||||
_remaining = expectedInputs;
|
||||
}
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
protocolBuilder.RouteBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
|
||||
{
|
||||
bool done;
|
||||
lock (_allResults)
|
||||
{
|
||||
_allResults.Add(messages);
|
||||
done = --_remaining == 0;
|
||||
}
|
||||
|
||||
if (!done)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_remaining = _expectedInputs;
|
||||
List<List<ChatMessage>> results = _allResults;
|
||||
_allResults = new List<List<ChatMessage>>(_expectedInputs);
|
||||
await context.YieldOutputAsync(_aggregator(results), cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return protocolBuilder.YieldsOutput<List<ChatMessage>>();
|
||||
}
|
||||
|
||||
public ValueTask ResetAsync()
|
||||
{
|
||||
_allResults = new List<List<ChatMessage>>(_expectedInputs);
|
||||
_remaining = _expectedInputs;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRoundRobinGroupChatHost(
|
||||
string id,
|
||||
AIAgent[] agents,
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap,
|
||||
int maximumIterations)
|
||||
: ChatProtocolExecutor(id, s_options), IResettableExecutor
|
||||
{
|
||||
private static readonly ChatProtocolExecutorOptions s_options = new()
|
||||
{
|
||||
StringMessageChatRole = ChatRole.User,
|
||||
AutoSendTurnToken = false,
|
||||
};
|
||||
|
||||
private readonly AIAgent[] _agents = agents;
|
||||
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
|
||||
private readonly int _maximumIterations = maximumIterations;
|
||||
private int _iterationCount;
|
||||
private int _nextIndex;
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
|
||||
|
||||
protected override async ValueTask TakeTurnAsync(
|
||||
List<ChatMessage> messages,
|
||||
IWorkflowContext context,
|
||||
bool? emitEvents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_iterationCount < _maximumIterations)
|
||||
{
|
||||
AIAgent nextAgent = _agents[_nextIndex];
|
||||
_nextIndex = (_nextIndex + 1) % _agents.Length;
|
||||
|
||||
if (_agentMap.TryGetValue(nextAgent, out ExecutorBinding? executor))
|
||||
{
|
||||
_iterationCount++;
|
||||
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_iterationCount = 0;
|
||||
_nextIndex = 0;
|
||||
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override ValueTask ResetAsync()
|
||||
{
|
||||
_iterationCount = 0;
|
||||
_nextIndex = 0;
|
||||
return base.ResetAsync();
|
||||
}
|
||||
|
||||
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
|
||||
}
|
||||
@@ -57,12 +57,17 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentActivityEventDto CreateToolCallingActivity(
|
||||
private static AgentActivityEventDto? CreateToolCallingActivity(
|
||||
RunTurnCommandDto command,
|
||||
AgentIdentity activeAgent,
|
||||
ToolRequestInterpretation tool,
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||
{
|
||||
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
|
||||
|
||||
return new AgentActivityEventDto
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class AgentInstructionComposerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_StrengthensHandoffTriageInstructions()
|
||||
public void Compose_LeavesHandoffTriagePromptFocusedOnAgentInstructions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
@@ -70,14 +70,13 @@ public sealed class AgentInstructionComposerTests
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0);
|
||||
|
||||
Assert.Contains("routing gate", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not inspect files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not claim that you handed work off", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("You triage requests and must hand them off to the most appropriate specialist.", instructions);
|
||||
Assert.DoesNotContain("routing", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_StrengthensHandoffSpecialistInstructions()
|
||||
public void Compose_LeavesHandoffSpecialistPromptFocusedOnAgentInstructions()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
@@ -93,8 +92,9 @@ public sealed class AgentInstructionComposerTests
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1);
|
||||
|
||||
Assert.Contains("Once the triage agent hands work to you", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("You focus on navigation, UX, and interaction details.", instructions);
|
||||
Assert.DoesNotContain("triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,6 +4,7 @@ using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
@@ -111,6 +112,54 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Throws<NotSupportedException>(() => AryxCopilotAgent.CreateConfiguredSessionConfig(new SessionConfig(), options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHandoffWorkflowBuilder_ExplicitlyUsesHandoffOnlyFiltering()
|
||||
{
|
||||
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
|
||||
|
||||
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
|
||||
|
||||
FieldInfo field = typeof(HandoffsWorkflowBuilder).GetField(
|
||||
"_toolCallFilteringBehavior",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
|
||||
|
||||
HandoffToolCallFilteringBehavior behavior = Assert.IsType<HandoffToolCallFilteringBehavior>(field.GetValue(builder));
|
||||
|
||||
Assert.Equal(HandoffToolCallFilteringBehavior.HandoffOnly, behavior);
|
||||
Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("single", 1)]
|
||||
[InlineData("sequential", 2)]
|
||||
[InlineData("concurrent", 2)]
|
||||
[InlineData("group-chat", 2)]
|
||||
public void BuildWorkflow_ExplicitlyConfiguresAgentHostOptions(string mode, int agentCount)
|
||||
{
|
||||
CopilotAgentBundle bundle = new(CreateAgents(agentCount), hasConfiguredHooks: false);
|
||||
PatternDefinitionDto pattern = CreatePattern(mode, agentCount);
|
||||
|
||||
Workflow workflow = bundle.BuildWorkflow(pattern);
|
||||
|
||||
AIAgentBinding[] bindings = workflow.ReflectExecutors().Values
|
||||
.OfType<AIAgentBinding>()
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(agentCount, bindings.Length);
|
||||
|
||||
foreach (AIAgentBinding binding in bindings)
|
||||
{
|
||||
AIAgentHostOptions options = Assert.IsType<AIAgentHostOptions>(binding.Options);
|
||||
Assert.Null(options.EmitAgentUpdateEvents);
|
||||
Assert.False(options.EmitAgentResponseEvents);
|
||||
Assert.False(options.InterceptUserInputRequests);
|
||||
Assert.False(options.InterceptUnterminatedFunctionCalls);
|
||||
Assert.True(options.ReassignOtherAgentsAsUsers);
|
||||
Assert.True(options.ForwardIncomingMessages);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_MapsCallIdsNamesAndArguments()
|
||||
{
|
||||
@@ -136,7 +185,7 @@ public sealed class CopilotAgentBundleTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
|
||||
public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls()
|
||||
{
|
||||
AssistantMessageDataToolRequestsItem[] toolRequests =
|
||||
{
|
||||
@@ -148,9 +197,99 @@ public sealed class CopilotAgentBundleTests
|
||||
|
||||
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
|
||||
|
||||
FunctionCallContent single = Assert.Single(result);
|
||||
Assert.Equal("call-003", single.CallId);
|
||||
Assert.Equal("handoff_to_reviewer", single.Name);
|
||||
Assert.Collection(
|
||||
result,
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-001", functionCall.CallId);
|
||||
Assert.Equal("ask_user", functionCall.Name);
|
||||
},
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-002", functionCall.CallId);
|
||||
Assert.Equal("web_fetch", functionCall.Name);
|
||||
},
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-003", functionCall.CallId);
|
||||
Assert.Equal("handoff_to_reviewer", functionCall.Name);
|
||||
},
|
||||
functionCall =>
|
||||
{
|
||||
Assert.Equal("call-004", functionCall.CallId);
|
||||
Assert.Equal("grep", functionCall.Name);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateToolResultContent_UsesSdkResultContentForNonHandoffTools()
|
||||
{
|
||||
ToolExecutionCompleteEvent toolExecutionComplete = new()
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
Success = true,
|
||||
Result = new ToolExecutionCompleteDataResult
|
||||
{
|
||||
Content = "Search complete.",
|
||||
DetailedContent = "Search complete with extra context.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "rg");
|
||||
|
||||
Assert.NotNull(toolResult);
|
||||
Assert.Equal("call-123", toolResult.CallId);
|
||||
Assert.Equal("Search complete.", Assert.IsType<string>(toolResult.Result));
|
||||
Assert.Same(toolExecutionComplete, toolResult.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateToolResultContent_UsesSdkErrorMessageForFailedTools()
|
||||
{
|
||||
ToolExecutionCompleteEvent toolExecutionComplete = new()
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-456",
|
||||
Success = false,
|
||||
Error = new ToolExecutionCompleteDataError
|
||||
{
|
||||
Message = "Permission denied.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "view");
|
||||
|
||||
Assert.NotNull(toolResult);
|
||||
Assert.Equal("call-456", toolResult.CallId);
|
||||
Assert.Equal("Permission denied.", Assert.IsType<string>(toolResult.Result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateToolResultContent_SkipsHandoffTools()
|
||||
{
|
||||
ToolExecutionCompleteEvent toolExecutionComplete = new()
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-789",
|
||||
Success = true,
|
||||
Result = new ToolExecutionCompleteDataResult
|
||||
{
|
||||
Content = "Transferred.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(
|
||||
toolExecutionComplete,
|
||||
"handoff_to_reviewer");
|
||||
|
||||
Assert.Null(toolResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -369,8 +508,75 @@ public sealed class CopilotAgentBundleTests
|
||||
CreateTool().JsonSchema);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AIAgent> CreateAgents(int count)
|
||||
=> Enumerable.Range(1, count)
|
||||
.Select(index => (AIAgent)CreateChatClientAgent($"agent-{index}", $"Agent {index}"))
|
||||
.ToArray();
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(string mode, int agentCount)
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
{
|
||||
Id = $"pattern-{mode}",
|
||||
Name = $"Pattern {mode}",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
.. Enumerable.Range(1, agentCount).Select(index => new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = $"agent-{index}",
|
||||
Name = $"Agent {index}",
|
||||
Description = $"Agent {index} description.",
|
||||
Instructions = $"Agent {index} instructions.",
|
||||
Model = "gpt-5.4",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateChatClientAgent(string id, string name)
|
||||
{
|
||||
return new ChatClientAgent(
|
||||
new StubChatClient(),
|
||||
id,
|
||||
name,
|
||||
"Stub agent for handoff builder tests.",
|
||||
[],
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private sealed class ToolTarget
|
||||
{
|
||||
public string Echo() => "ok";
|
||||
}
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId()
|
||||
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
@@ -68,22 +68,32 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "tool.execution_start",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"toolName": "view"
|
||||
},
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("tool-calling", toolActivity.ActivityType);
|
||||
Assert.Equal("view", toolActivity.ToolName);
|
||||
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||
|
||||
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.Equal("handoff_to_specialist", toolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
|
||||
{
|
||||
@@ -178,6 +188,10 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
|
||||
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
|
||||
|
||||
AgentActivityEventDto[] toolActivities = [.. pending.OfType<AgentActivityEventDto>().Where(activity => activity.ActivityType == "tool-calling")];
|
||||
Assert.Equal(2, toolActivities.Length);
|
||||
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg");
|
||||
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
|
||||
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
|
||||
Assert.Equal("msg-3", reclassified.MessageId);
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
@@ -798,6 +799,216 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_FallsBackToActiveAgentForUnresolvedStreamingUpdates()
|
||||
{
|
||||
RunTurnCommandDto command = CreateHandoffCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
state.ObserveSessionEvent(
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning_delta",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-1",
|
||||
"deltaContent": "Polishing the UI."
|
||||
},
|
||||
"id": "77777777-7777-7777-7777-777777777777",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
List<TurnDeltaEventDto> deltas = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new AgentResponseUpdateEvent(
|
||||
"copilot-executor-ux",
|
||||
new AgentResponseUpdate(ChatRole.Assistant, "The button is ready.")
|
||||
{
|
||||
MessageId = "msg-ux-1",
|
||||
}),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(delta =>
|
||||
{
|
||||
deltas.Add(delta);
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
(Func<SidecarEventDto, Task>)(_ => Task.CompletedTask),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
TurnDeltaEventDto delta = Assert.Single(deltas);
|
||||
Assert.Equal("msg-ux-1", delta.MessageId);
|
||||
Assert.Equal("UX Specialist", delta.AuthorName);
|
||||
Assert.Equal("The button is ready.", delta.ContentDelta);
|
||||
Assert.Equal("The button is ready.", delta.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsWorkflowCheckpointSavedEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateHandoffCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
List<WorkflowCheckpointSavedEventDto> checkpoints = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
new SuperStepCompletedEvent(
|
||||
3,
|
||||
new SuperStepCompletionInfo([])
|
||||
{
|
||||
Checkpoint = new CheckpointInfo(command.RequestId, "checkpoint-1"),
|
||||
}),
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
checkpoints.Add(Assert.IsType<WorkflowCheckpointSavedEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
WorkflowCheckpointSavedEventDto checkpoint = Assert.Single(checkpoints);
|
||||
Assert.Equal("workflow-checkpoint-saved", checkpoint.Type);
|
||||
Assert.Equal(command.SessionId, checkpoint.SessionId);
|
||||
Assert.Equal(command.RequestId, checkpoint.WorkflowSessionId);
|
||||
Assert.Equal("checkpoint-1", checkpoint.CheckpointId);
|
||||
Assert.Equal(3, checkpoint.StepNumber);
|
||||
Assert.EndsWith(
|
||||
Path.Combine("Aryx", "workflow-checkpoints", command.SessionId, command.RequestId),
|
||||
checkpoint.StorePath);
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
||||
@@ -11,8 +11,12 @@ public sealed class HandoffWorkflowGuidanceTests
|
||||
string instructions = HandoffWorkflowGuidance.CreateWorkflowInstructions();
|
||||
|
||||
Assert.Contains("explicit handoffs", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("routing or triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("best specialist", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not inspect files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not claim that you delegated", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not narrate a handoff", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Specialists should complete the substantive work", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -86,6 +86,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
||||
Assert.Empty(toolNamesByCallId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
|
||||
{
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
|
||||
{
|
||||
["call-1"] = "view",
|
||||
};
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
|
||||
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
CreateSingleAgentCommand(),
|
||||
requestInfo,
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
toolNamesByCallId);
|
||||
|
||||
Assert.Null(activity);
|
||||
Assert.Equal("view", toolNamesByCallId["call-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
|
||||
{
|
||||
|
||||
+188
-16
@@ -12,10 +12,13 @@ import type {
|
||||
McpOauthRequiredEvent,
|
||||
MessageReclassifiedEvent,
|
||||
RunTurnCustomAgentConfig,
|
||||
RunTurnCommand,
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
UserInputRequestedEvent,
|
||||
TurnDeltaEvent,
|
||||
WorkflowCheckpointResume,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
import {
|
||||
@@ -104,6 +107,7 @@ import {
|
||||
upsertRunApprovalEvent,
|
||||
upsertRunMessageEvent,
|
||||
upsertSessionRunRecord,
|
||||
type RunTimelineEventRecord,
|
||||
type SessionRunRecord,
|
||||
} from '@shared/domain/runTimeline';
|
||||
import {
|
||||
@@ -170,6 +174,15 @@ type PendingUserInputHandle = {
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type WorkflowCheckpointRecoveryState = {
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
stepNumber: number;
|
||||
sessionMessages: ChatMessageRecord[];
|
||||
runEvents: RunTimelineEventRecord[];
|
||||
};
|
||||
|
||||
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
@@ -190,6 +203,16 @@ function isSidecarStoppedBeforeCompletionError(error: unknown): error is Error {
|
||||
return error instanceof Error && error.message === SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE;
|
||||
}
|
||||
|
||||
function isUnexpectedSidecarTerminationError(error: unknown): error is Error {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { message } = error;
|
||||
return isSidecarStoppedBeforeCompletionError(error)
|
||||
|| message.startsWith('The .NET sidecar exited unexpectedly with code ');
|
||||
}
|
||||
|
||||
const INTERRUPTED_RUN_ERROR =
|
||||
'This session was interrupted because Aryx restarted while a run was in progress.';
|
||||
const INTERRUPTED_APPROVAL_ERROR =
|
||||
@@ -208,6 +231,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly ptyManager = new PtyManager();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
private readonly workflowCheckpointRecoveries = new Map<string, WorkflowCheckpointRecoveryState>();
|
||||
private workspace?: WorkspaceState;
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||
@@ -1407,21 +1431,29 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
});
|
||||
|
||||
try {
|
||||
const responseMessages = await this.sidecar.runTurn(
|
||||
{
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: runWorkingDirectory,
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||
},
|
||||
const createRunTurnCommand = (
|
||||
resumeFromCheckpoint?: WorkflowCheckpointResume,
|
||||
): RunTurnCommand => ({
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: runWorkingDirectory,
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||
resumeFromCheckpoint,
|
||||
});
|
||||
|
||||
const responseMessages = await this.runSidecarTurnWithCheckpointRecovery(
|
||||
workspace,
|
||||
session,
|
||||
requestId,
|
||||
createRunTurnCommand,
|
||||
async (event) => {
|
||||
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
||||
},
|
||||
@@ -1459,6 +1491,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
await this.persistAndBroadcast(workspace);
|
||||
await this.cleanupWorkflowCheckpointRecovery(requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
@@ -1472,6 +1505,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
await this.persistAndBroadcast(workspace);
|
||||
await this.cleanupWorkflowCheckpointRecovery(requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
@@ -1504,6 +1538,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
await this.cleanupWorkflowCheckpointRecovery(requestId);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
@@ -2421,13 +2456,22 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
private handleTurnScopedEvent(
|
||||
_workspace: WorkspaceState,
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnScopedEvent,
|
||||
): void {
|
||||
const occurredAt = nowIso();
|
||||
|
||||
switch (event.type) {
|
||||
case 'workflow-checkpoint-saved': {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const run = session.runs.find((candidate) => candidate.requestId === event.requestId);
|
||||
if (run) {
|
||||
this.recordWorkflowCheckpointRecovery(session, run, event);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
case 'subagent-event':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
@@ -2504,6 +2548,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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,
|
||||
@@ -2525,6 +2584,119 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private async runSidecarTurnWithCheckpointRecovery(
|
||||
workspace: WorkspaceState,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
const invokeTurn = (resumeFromCheckpoint?: WorkflowCheckpointResume) => this.sidecar.runTurn(
|
||||
createCommand(resumeFromCheckpoint),
|
||||
onDelta,
|
||||
onActivity,
|
||||
onApproval,
|
||||
onUserInput,
|
||||
onMcpOAuthRequired,
|
||||
onExitPlanMode,
|
||||
onMessageReclassified,
|
||||
onTurnScopedEvent,
|
||||
);
|
||||
|
||||
try {
|
||||
return await invokeTurn();
|
||||
} catch (error) {
|
||||
const recovery = this.workflowCheckpointRecoveries.get(requestId);
|
||||
if (!isUnexpectedSidecarTerminationError(error) || !recovery) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
if (restoredRun) {
|
||||
this.emitRunUpdated(session.id, session.updatedAt, restoredRun);
|
||||
}
|
||||
|
||||
return invokeTurn({
|
||||
workflowSessionId: recovery.workflowSessionId,
|
||||
checkpointId: recovery.checkpointId,
|
||||
storePath: recovery.storePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private recordWorkflowCheckpointRecovery(
|
||||
session: SessionRecord,
|
||||
run: SessionRunRecord,
|
||||
event: WorkflowCheckpointSavedEvent,
|
||||
): void {
|
||||
this.workflowCheckpointRecoveries.set(event.requestId, {
|
||||
workflowSessionId: event.workflowSessionId,
|
||||
checkpointId: event.checkpointId,
|
||||
storePath: event.storePath,
|
||||
stepNumber: event.stepNumber,
|
||||
sessionMessages: structuredClone(session.messages),
|
||||
runEvents: structuredClone(run.events),
|
||||
});
|
||||
}
|
||||
|
||||
private restoreWorkflowCheckpointRecovery(
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
recovery: WorkflowCheckpointRecoveryState,
|
||||
): SessionRunRecord | undefined {
|
||||
session.messages = structuredClone(recovery.sessionMessages);
|
||||
session.status = 'running';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
this.clearPendingRunState(session, requestId);
|
||||
|
||||
return this.updateSessionRun(session, requestId, (run) => ({
|
||||
...run,
|
||||
events: structuredClone(recovery.runEvents),
|
||||
}));
|
||||
}
|
||||
|
||||
private clearPendingRunState(session: SessionRecord, requestId: string): void {
|
||||
this.setSessionPendingApprovalState(session, {});
|
||||
session.pendingUserInput = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
session.pendingMcpAuth = undefined;
|
||||
|
||||
for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) {
|
||||
if (handle.sessionId === session.id && handle.requestId === requestId) {
|
||||
this.pendingApprovalHandles.delete(approvalId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) {
|
||||
if (handle.sessionId === session.id && handle.requestId === requestId) {
|
||||
this.pendingUserInputHandles.delete(userInputId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupWorkflowCheckpointRecovery(requestId: string): Promise<void> {
|
||||
const recovery = this.workflowCheckpointRecoveries.get(requestId);
|
||||
this.workflowCheckpointRecoveries.delete(requestId);
|
||||
if (!recovery) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(recovery.storePath, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
|
||||
@@ -12,9 +12,11 @@ import type {
|
||||
SessionUsageEvent,
|
||||
SessionCompactionEvent,
|
||||
PendingMessagesModifiedEvent,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
AssistantUsageEvent,
|
||||
AssistantIntentEvent,
|
||||
ReasoningDeltaEvent,
|
||||
WorkflowDiagnosticEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
@@ -25,9 +27,11 @@ export type TurnScopedEvent =
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| WorkflowCheckpointSavedEvent
|
||||
| AssistantUsageEvent
|
||||
| AssistantIntentEvent
|
||||
| ReasoningDeltaEvent;
|
||||
| ReasoningDeltaEvent
|
||||
| WorkflowDiagnosticEvent;
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
kind: 'run-turn';
|
||||
|
||||
@@ -452,11 +452,13 @@ export class SidecarClient {
|
||||
case 'skill-invoked':
|
||||
case 'hook-lifecycle':
|
||||
case 'session-usage':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
case 'assistant-usage':
|
||||
case 'assistant-intent':
|
||||
case 'reasoning-delta':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
case 'workflow-checkpoint-saved':
|
||||
case 'workflow-diagnostic':
|
||||
case 'assistant-usage':
|
||||
case 'assistant-intent':
|
||||
case 'reasoning-delta':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -190,6 +190,8 @@ function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase
|
||||
return <Sparkles className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||
case 'session-compaction':
|
||||
return <CheckCircle2 className={`${base} ${phase === 'start' ? 'animate-pulse text-[var(--color-status-warning)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
case 'workflow-diagnostic':
|
||||
return <AlertTriangle className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-warning)]'}`} />;
|
||||
default:
|
||||
return <Zap className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
}
|
||||
|
||||
@@ -535,13 +535,7 @@ export function ChatPane({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && message.pending ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
|
||||
{message.content}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent content={message.content} />
|
||||
)}
|
||||
<MarkdownContent content={message.content} />
|
||||
{message.pending && message.content && (
|
||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
|
||||
|
||||
export interface AgentActivityState {
|
||||
agentId: string;
|
||||
@@ -260,6 +260,22 @@ function formatHookType(hookType: string | undefined): string {
|
||||
return hookTypeLabels[hookType] ?? hookType;
|
||||
}
|
||||
|
||||
const diagnosticLabels: Record<WorkflowDiagnosticKind, string> = {
|
||||
'workflow-warning': 'Workflow warning',
|
||||
'workflow-error': 'Workflow error',
|
||||
'executor-failed': 'Executor failed',
|
||||
'subworkflow-warning': 'Subworkflow warning',
|
||||
'subworkflow-error': 'Subworkflow error',
|
||||
};
|
||||
|
||||
function formatDiagnosticLabel(
|
||||
kind: WorkflowDiagnosticKind | undefined,
|
||||
severity: WorkflowDiagnosticSeverity | undefined,
|
||||
): string {
|
||||
if (kind) return diagnosticLabels[kind] ?? kind;
|
||||
return severity === 'error' ? 'Workflow error' : 'Workflow warning';
|
||||
}
|
||||
|
||||
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
|
||||
switch (event.kind) {
|
||||
case 'subagent':
|
||||
@@ -300,6 +316,21 @@ function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undef
|
||||
phase: event.compactionPhase,
|
||||
success: event.compactionSuccess,
|
||||
};
|
||||
case 'workflow-diagnostic': {
|
||||
const label = formatDiagnosticLabel(event.diagnosticKind, event.diagnosticSeverity);
|
||||
const detailParts: string[] = [];
|
||||
if (event.executorId) detailParts.push(event.executorId);
|
||||
if (event.subworkflowId) detailParts.push(event.subworkflowId);
|
||||
if (event.exceptionType) detailParts.push(event.exceptionType);
|
||||
if (event.diagnosticMessage) detailParts.push(event.diagnosticMessage);
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label,
|
||||
detail: detailParts.length > 0 ? detailParts.join(' · ') : undefined,
|
||||
success: event.diagnosticSeverity === 'error' ? false : undefined,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ export interface ValidatePatternCommand {
|
||||
export type InteractionMode = 'interactive' | 'plan';
|
||||
export type MessageMode = 'enqueue' | 'immediate';
|
||||
|
||||
export interface WorkflowCheckpointResume {
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
}
|
||||
|
||||
export interface RunTurnCommand {
|
||||
type: 'run-turn';
|
||||
requestId: string;
|
||||
@@ -85,6 +91,7 @@ export interface RunTurnCommand {
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
tooling?: RunTurnToolingConfig;
|
||||
resumeFromCheckpoint?: WorkflowCheckpointResume;
|
||||
}
|
||||
|
||||
export interface CancelTurnCommand {
|
||||
@@ -384,6 +391,38 @@ export interface PendingMessagesModifiedEvent {
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowCheckpointSavedEvent {
|
||||
type: 'workflow-checkpoint-saved';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
workflowSessionId: string;
|
||||
checkpointId: string;
|
||||
storePath: string;
|
||||
stepNumber: number;
|
||||
}
|
||||
|
||||
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 +602,8 @@ export type SidecarEvent =
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| WorkflowCheckpointSavedEvent
|
||||
| WorkflowDiagnosticEvent
|
||||
| ApprovalRequestedEvent
|
||||
| UserInputRequestedEvent
|
||||
| McpOauthRequiredEvent
|
||||
|
||||
@@ -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<string, QuotaSnapshot>;
|
||||
|
||||
// Workflow diagnostic fields
|
||||
diagnosticSeverity?: WorkflowDiagnosticSeverity;
|
||||
diagnosticKind?: WorkflowDiagnosticKind;
|
||||
diagnosticMessage?: string;
|
||||
executorId?: string;
|
||||
subworkflowId?: string;
|
||||
exceptionType?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { RunTurnCommand, WorkflowCheckpointSavedEvent, WorkflowCheckpointResume } from '@shared/contracts/sidecar';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
createSessionRunRecord,
|
||||
type RunTimelineEventRecord,
|
||||
type SessionRunRecord,
|
||||
} from '@shared/domain/runTimeline';
|
||||
|
||||
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 checkpointing', () => {
|
||||
test('records workflow checkpoint recovery snapshots from turn-scoped events', async () => {
|
||||
const service = new AryxAppService();
|
||||
const { session, run } = createRunningSession();
|
||||
const checkpointEvent: WorkflowCheckpointSavedEvent = {
|
||||
type: 'workflow-checkpoint-saved',
|
||||
requestId: run.requestId,
|
||||
sessionId: session.id,
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 2,
|
||||
};
|
||||
|
||||
const internals = service as unknown as {
|
||||
workflowCheckpointRecoveries: Map<string, unknown>;
|
||||
handleTurnScopedEvent: (
|
||||
workspace: { sessions: SessionRecord[] },
|
||||
sessionId: string,
|
||||
event: WorkflowCheckpointSavedEvent,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
await internals.handleTurnScopedEvent({ sessions: [session] }, session.id, checkpointEvent);
|
||||
|
||||
expect(internals.workflowCheckpointRecoveries.get(run.requestId)).toEqual({
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: checkpointEvent.storePath,
|
||||
stepNumber: 2,
|
||||
sessionMessages: session.messages,
|
||||
runEvents: run.events,
|
||||
});
|
||||
});
|
||||
|
||||
test('retries a checkpointed turn with resume metadata after sidecar exit', async () => {
|
||||
const service = new AryxAppService();
|
||||
const { session, run } = createRunningSession();
|
||||
const workspace = { sessions: [session] };
|
||||
const checkpointRecovery = {
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-7',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 7,
|
||||
sessionMessages: structuredClone(session.messages),
|
||||
runEvents: structuredClone(run.events),
|
||||
};
|
||||
const invocations: RunTurnCommand[] = [];
|
||||
|
||||
session.messages.push({
|
||||
id: 'msg-partial',
|
||||
role: 'assistant',
|
||||
authorName: 'Primary',
|
||||
content: 'Partial output after the checkpoint.',
|
||||
createdAt: '2026-04-01T12:00:05.000Z',
|
||||
pending: true,
|
||||
});
|
||||
run.events = [
|
||||
...run.events,
|
||||
{
|
||||
id: 'run-event-extra',
|
||||
kind: 'message',
|
||||
occurredAt: '2026-04-01T12:00:05.000Z',
|
||||
status: 'running',
|
||||
messageId: 'msg-partial',
|
||||
content: 'Partial output after the checkpoint.',
|
||||
} satisfies RunTimelineEventRecord,
|
||||
];
|
||||
session.pendingUserInput = {
|
||||
id: 'user-input-1',
|
||||
status: 'pending',
|
||||
requestedAt: '2026-04-01T12:00:05.000Z',
|
||||
question: 'Need more detail?',
|
||||
choices: ['Yes', 'No'],
|
||||
allowFreeform: true,
|
||||
};
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
workflowCheckpointRecoveries: Map<string, unknown>;
|
||||
sidecar: {
|
||||
runTurn: (
|
||||
command: RunTurnCommand,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
};
|
||||
persistAndBroadcast: (workspace: unknown) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
runSidecarTurnWithCheckpointRecovery: (
|
||||
workspace: unknown,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: () => Promise<void>,
|
||||
onActivity: () => Promise<void>,
|
||||
onApproval: () => Promise<void>,
|
||||
onUserInput: () => Promise<void>,
|
||||
onMcpOAuthRequired: () => Promise<void>,
|
||||
onExitPlanMode: () => Promise<void>,
|
||||
onMessageReclassified: () => Promise<void>,
|
||||
onTurnScopedEvent: () => Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
}
|
||||
).workflowCheckpointRecoveries.set(run.requestId, checkpointRecovery);
|
||||
(
|
||||
service as unknown as {
|
||||
sidecar: {
|
||||
runTurn: (command: RunTurnCommand) => Promise<ChatMessageRecord[]>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
runTurn: async (command: RunTurnCommand) => {
|
||||
invocations.push(structuredClone(command));
|
||||
if (invocations.length === 1) {
|
||||
throw new Error('The .NET sidecar exited unexpectedly with code 1.');
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
(
|
||||
service as unknown as {
|
||||
persistAndBroadcast: (workspace: unknown) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
}
|
||||
).persistAndBroadcast = async () => undefined;
|
||||
(
|
||||
service as unknown as {
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
}
|
||||
).emitRunUpdated = () => undefined;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
runSidecarTurnWithCheckpointRecovery: (
|
||||
workspace: unknown,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: () => Promise<void>,
|
||||
onActivity: () => Promise<void>,
|
||||
onApproval: () => Promise<void>,
|
||||
onUserInput: () => Promise<void>,
|
||||
onMcpOAuthRequired: () => Promise<void>,
|
||||
onExitPlanMode: () => Promise<void>,
|
||||
onMessageReclassified: () => Promise<void>,
|
||||
onTurnScopedEvent: () => Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
}
|
||||
).runSidecarTurnWithCheckpointRecovery(
|
||||
workspace,
|
||||
session,
|
||||
run.requestId,
|
||||
(resumeFromCheckpoint?: WorkflowCheckpointResume): RunTurnCommand => ({
|
||||
type: 'run-turn',
|
||||
requestId: run.requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: 'C:\\scratchpad',
|
||||
workspaceKind: 'scratchpad',
|
||||
mode: 'interactive',
|
||||
messageMode: 'enqueue',
|
||||
pattern: createPattern(),
|
||||
messages: session.messages,
|
||||
resumeFromCheckpoint,
|
||||
}),
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[0]?.resumeFromCheckpoint).toBeUndefined();
|
||||
expect(invocations[1]?.resumeFromCheckpoint).toEqual({
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-7',
|
||||
storePath: checkpointRecovery.storePath,
|
||||
});
|
||||
expect(invocations[1]?.messages).toEqual(checkpointRecovery.sessionMessages);
|
||||
expect(session.messages).toEqual(checkpointRecovery.sessionMessages);
|
||||
expect(session.pendingUserInput).toBeUndefined();
|
||||
expect(session.runs[0]?.events).toEqual(checkpointRecovery.runEvents);
|
||||
});
|
||||
});
|
||||
|
||||
function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } {
|
||||
const pattern = createPattern();
|
||||
const run = createSessionRunRecord({
|
||||
requestId: 'turn-1',
|
||||
project: {
|
||||
id: SCRATCHPAD_PROJECT_ID,
|
||||
path: 'C:\\scratchpad',
|
||||
},
|
||||
workingDirectory: 'C:\\scratchpad',
|
||||
workspaceKind: 'scratchpad',
|
||||
pattern,
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const session: SessionRecord = {
|
||||
id: 'session-1',
|
||||
projectId: SCRATCHPAD_PROJECT_ID,
|
||||
patternId: pattern.id,
|
||||
title: 'Checkpoint session',
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
updatedAt: '2026-04-01T12:00:00.000Z',
|
||||
status: 'running',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: 'Continue the workflow.',
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-assistant-1',
|
||||
role: 'assistant',
|
||||
authorName: 'Primary',
|
||||
content: 'Working on it.',
|
||||
createdAt: '2026-04-01T12:00:01.000Z',
|
||||
pending: true,
|
||||
},
|
||||
],
|
||||
runs: [run],
|
||||
};
|
||||
|
||||
return { session, run };
|
||||
}
|
||||
|
||||
function createPattern() {
|
||||
return {
|
||||
id: 'pattern-handoff',
|
||||
name: 'Checkpointing flow',
|
||||
description: '',
|
||||
mode: 'handoff' as const,
|
||||
availability: 'available' as const,
|
||||
maxIterations: 4,
|
||||
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',
|
||||
};
|
||||
}
|
||||
@@ -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,12 @@ import { EventEmitter } from 'node:events';
|
||||
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
import type {
|
||||
RunTurnCommand,
|
||||
SidecarCapabilities,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
WorkflowDiagnosticEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
|
||||
class FakeReadableStream extends EventEmitter {
|
||||
setEncoding(_encoding: BufferEncoding): void {}
|
||||
@@ -142,4 +147,192 @@ 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;
|
||||
});
|
||||
|
||||
test('routes workflow checkpoint events through the turn-scoped callback', async () => {
|
||||
spawnedProcesses.length = 0;
|
||||
const client = new SidecarClient();
|
||||
const checkpoints: WorkflowCheckpointSavedEvent[] = [];
|
||||
const command: RunTurnCommand = {
|
||||
type: 'run-turn',
|
||||
requestId: 'turn-1',
|
||||
sessionId: 'session-1',
|
||||
projectPath: 'C:\\workspace\\project',
|
||||
pattern: {
|
||||
id: 'pattern-1',
|
||||
name: 'Handoff',
|
||||
description: '',
|
||||
mode: 'handoff',
|
||||
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-checkpoint-saved') {
|
||||
checkpoints.push(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(spawnedProcesses).toHaveLength(1);
|
||||
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'workflow-checkpoint-saved',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
workflowSessionId: 'turn-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 2,
|
||||
} satisfies WorkflowCheckpointSavedEvent)}\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(checkpoints).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow-checkpoint-saved',
|
||||
workflowSessionId: 'turn-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
stepNumber: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dispose = client.dispose();
|
||||
spawnedProcesses[0]!.completeExit();
|
||||
await dispose;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
applyAssistantUsageEvent,
|
||||
applyTurnEventLog,
|
||||
buildAgentActivityRows,
|
||||
formatAgentActivityLabel,
|
||||
formatDuration,
|
||||
@@ -527,3 +528,79 @@ describe('usage formatting helpers', () => {
|
||||
expect(formatDuration(150_000)).toBe('2.5m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow diagnostic turn events', () => {
|
||||
function makeDiagnosticEvent(overrides: Partial<SessionEventRecord> = {}): SessionEventRecord {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
kind: 'workflow-diagnostic',
|
||||
occurredAt: '2026-03-23T00:00:00.000Z',
|
||||
diagnosticSeverity: 'error',
|
||||
diagnosticKind: 'executor-failed',
|
||||
diagnosticMessage: 'Tool crashed.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('formats executor-failed with full metadata', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
executorId: 'Primary',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].label).toBe('Executor failed');
|
||||
expect(entries[0].detail).toBe('Primary · InvalidOperationException · Tool crashed.');
|
||||
expect(entries[0].success).toBe(false);
|
||||
});
|
||||
|
||||
test('formats workflow-warning with message only', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticSeverity: 'warning',
|
||||
diagnosticKind: 'workflow-warning',
|
||||
diagnosticMessage: 'Token budget is nearly exhausted.',
|
||||
executorId: undefined,
|
||||
exceptionType: undefined,
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Workflow warning');
|
||||
expect(entries[0].detail).toBe('Token budget is nearly exhausted.');
|
||||
expect(entries[0].success).toBeUndefined();
|
||||
});
|
||||
|
||||
test('formats subworkflow-error with subworkflow ID', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticKind: 'subworkflow-error',
|
||||
subworkflowId: 'subworkflow-review',
|
||||
exceptionType: 'InvalidOperationException',
|
||||
diagnosticMessage: 'Reviewer agent failed.',
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Subworkflow error');
|
||||
expect(entries[0].detail).toBe('subworkflow-review · InvalidOperationException · Reviewer agent failed.');
|
||||
});
|
||||
|
||||
test('formats workflow-error without optional fields', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticKind: 'workflow-error',
|
||||
diagnosticMessage: 'Workflow terminated unexpectedly.',
|
||||
executorId: undefined,
|
||||
subworkflowId: undefined,
|
||||
exceptionType: undefined,
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Workflow error');
|
||||
expect(entries[0].detail).toBe('Workflow terminated unexpectedly.');
|
||||
expect(entries[0].success).toBe(false);
|
||||
});
|
||||
|
||||
test('falls back to severity when diagnosticKind is missing', () => {
|
||||
const result = applyTurnEventLog({}, makeDiagnosticEvent({
|
||||
diagnosticKind: undefined,
|
||||
diagnosticSeverity: 'warning',
|
||||
diagnosticMessage: 'Something odd happened.',
|
||||
}));
|
||||
const entries = result['session-1']!;
|
||||
expect(entries[0].label).toBe('Workflow warning');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user