Compare commits

...
79 Commits
Author SHA1 Message Date
David Kaya 9f85cce418 chore: bump version to 0.0.22 2026-04-07 18:21:25 +02:00
David Kaya 33773c868f Merge branch 'copilot/amused-horse' 2026-04-07 17:54:46 +02:00
David KayaandCopilot 507bd5408c feat: show expandable tool call details in chat activity panel
Thread toolArguments from the sidecar through shared contracts, main-process pipeline, and into the renderer. Add ToolCallDetailPanel with inline summaries (command, path, pattern, etc.) and expandable argument details for every tool-call event in the activity timeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:58:22 +02:00
David KayaandCopilot 7e37d75116 fix: emit completed activity for sequential agents
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:53:08 +02:00
David KayaandCopilot fd9cc03c5c feat: capture tool call arguments in sidecar activity events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:50:22 +02:00
David KayaandCopilot 8797186a73 feat(website): add screenshot lightbox and update orchestration screenshots
Add click-to-enlarge lightbox for both hero and orchestration
screenshots. Clicking opens a fullscreen overlay with smooth scale
animation; clicking the backdrop, close button, or pressing Escape
dismisses it. Screenshots are keyboard-accessible (Enter/Space) and
the overlay uses role=dialog with aria-modal for screen readers.

Update orchestration screenshots (dark + light) with latest captures
showing the workflow designer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:20:27 +02:00
David KayaandCopilot 573efff647 docs(website): rename patterns to workflows and add Workflow Designer features
Update all 13 references from 'patterns' to 'workflows' across the
product website to reflect the patterns-to-workflows migration.

Terminology changes:
- Navigation links: Patterns -> Workflows (#patterns -> #workflows)
- Hero, feature cards, section headings, tech banner, get-started steps
- 'Visual Pattern Editor' -> 'Workflow Designer'
- 'Run Timeline' -> 'Turn Activity Panels' (inline in chat turns)

New Workflow Designer section highlighting shipped capabilities:
- Visual Graph Editor with interactive canvas
- Eight rich hand-crafted workflow templates
- Conditional edges and loop iteration controls
- Sub-workflow nesting with breadcrumb navigation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:15:54 +02:00
David Kaya b583f39a38 Merge branch 'copilot/recent-swordtail' 2026-04-07 12:30:44 +02:00
David KayaandCopilot 3a639813a9 fix: resolve sub-workflow agent discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 12:28:51 +02:00
David KayaandCopilot 0a5fa81111 fix: scope turn-activity panel metrics to per-agent events in sequential workflows
In sequential workflows, multiple collapsible turn-activity panels shared
the same SessionRunRecord and displayed identical aggregate metrics (tool
calls, approvals, etc.) instead of per-agent counts.

Root cause: ChatPane grouped thinking messages correctly per agent, but
passed the full unfiltered run object to every TurnActivityPanel, so
summarizeActivity() counted all events from the entire run for each panel.

Changes:
- Extract filterEventsByAgent() and summarizeActivity() to
  runTimelineFormatting.ts as pure, testable helpers
- Add agentNames (derived from thinking message authors) and isLastRunPanel
  to the DisplayItem turn-activity variant in ChatPane
- TurnActivityPanel now filters run.events through filterEventsByAgent()
  so each panel only counts events belonging to its agents
- Git summary / discard actions only render on the last panel of a run
- Per-agent timing derived from scoped events instead of run-level start

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 12:25:01 +02:00
David KayaandCopilot 778c3b4a5b fix: synchronize workflow iteration settings across edges, mode settings, and scaffold
For builder-based orchestration modes (group-chat, handoff), the loop edge
maxIterations, workflow settings.maxIterations, and mode-specific settings
(e.g. groupChat.maxRounds) were independently editable but only the mode
settings controlled runtime behavior. This caused confusion when changing
one did not update the others.

Changes:
- Add syncBuilderModeEdgeIterations() to keep loop edge maxIterations in
  sync with the authoritative mode settings for builder-based modes
- Wire sync into normalizeWorkflowDefinition so edges are consistent on
  load/normalization
- Update scaffoldGraphForMode to accept optional settings instead of
  hardcoded iteration values (4 for handoff, 5 for group-chat)
- Sync settings.maxIterations <-> groupChat.maxRounds bidirectionally
  when either changes in the UI
- Make loop edge controls read-only in the graph inspector for
  builder-based modes with explanatory text
- Add 8 consistency tests validating built-in workflows, scaffold
  behavior, and normalization sync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 11:04:38 +02:00
David KayaandCopilot 366200b29d fix: eliminate thinking message flash before reclassification
The optimistic fold previously required prior thinking messages in the
pending group (pendingThinking.length > 0) before it would absorb an
unclassified pending assistant message. When the first assistant message
in a turn arrived before any thinking messages were classified, it
briefly rendered as a full chat message, then snapped into the
collapsible panel once the message-reclassified event arrived.

Remove the pendingThinking.length guard so any trailing pending,
unclassified assistant message during an active turn is folded into
the turn-activity panel immediately, regardless of whether prior
thinking messages exist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 10:25:09 +02:00
David KayaandCopilot cbc05c8f08 fix: allow 'always' condition on loop edges when maxIterations is set
The WorkflowValidator rejected loop edges with an 'always' condition,
but maxIterations already guarantees termination at runtime (enforced by
WorkflowConditionEvaluator). This made built-in templates like
Collaborative Group Chat fail validation immediately.

Relax the check so 'always' conditions are accepted when maxIterations
provides the termination guarantee. The condition error now only fires
when neither a non-default condition nor a maxIterations cap is present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 10:11:49 +02:00
David KayaandCopilot ecba1be92a fix: show turn activity panel immediately when run starts
The panel was only emitted as a DisplayItem when classified thinking
messages existed. During most of the turn, thinking messages haven't
been reclassified yet, so the panel appeared late with all activities
already populated.

Two fixes:
- After the message loop, inject a turn-activity item for any active
  run that wasn't already attached to a thinking group. This makes the
  panel appear as soon as the run record exists.
- Remove the guard that required run events to exist before rendering.
  The panel now shows in its 'Working' state immediately when the run
  starts, and populates progressively as events arrive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 10:03:06 +02:00
David KayaandCopilot 3a71baf104 feat: move run timeline from side panel into inline chat turn activity panels
Replace the separate RunTimeline in the ActivityPanel with a generalized
TurnActivityPanel that renders inline in the chat pane between user
messages and assistant responses.

The new component:
- Shows all turn activities (thinking steps, tool calls, approvals,
  handoffs, run status) in a single collapsible panel
- Merges chat thinking messages with run timeline events into a
  chronological activity stream
- Auto-expands when a turn starts, auto-collapses on completion
- Displays a compact summary header (elapsed time, tool call count,
  handoff count, etc.) when collapsed
- Includes post-run git change summary with discard/commit actions
- Uses subtle entrance animations for rows

Removed components:
- ThinkingProcess.tsx (subsumed by TurnActivityPanel)
- RunTimeline.tsx (no longer needed as a standalone component)

The ActivityPanel retains its agents, session usage, and turn events
(diagnostics/hooks) sections.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 09:47:19 +02:00
David KayaandCopilot a3e0bd9244 fix: hide unimplemented telemetry toggles from workflow settings
Remove the OpenTelemetry and Filter Sensitive Data toggles from the
workflow settings panel. The backend does not consume these settings,
so exposing them is misleading. The domain model and DTOs are retained
so existing workflow definitions remain valid.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 09:24:48 +02:00
David KayaandCopilot d265db42e5 feat: add descriptive help text to graph designer properties
Add inline descriptions to workflow settings, orchestration mode,
edge inspector, node inspectors, and condition editor properties
so users understand what each option does at a glance.

- Execution Mode: explains off-thread vs lockstep behavior
- Max Iterations: clarifies it prevents runaway loops
- State Scopes: describes shared state containers
- Edge Kind: explains direct, fan-out, fan-in semantics
- Loop Edge: describes iterative cycle behavior
- Reasoning: explains effort vs answer quality tradeoff
- Triage Agent, Tool-Call Filtering, Max Rounds, etc.
- Result Variable, Require Approval, Port ID, Condition

Also enhances FormField component with optional description prop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 09:02:15 +02:00
David Kaya ad2eab73d7 Merge branch 'copilot/precious-seahorse' 2026-04-07 08:49:30 +02:00
David KayaandCopilot e79cbe8df9 feat: improve agent status and thinking indicator UX
- Show agent activity label in chat header (Thinking…, Using bash…, etc.)
  instead of an anonymous pulsing dot
- Fix ThinkingProcess elapsed timer to tick live every second using
  useElapsedTimer hook instead of stale useMemo computation
- Show completed/failed subagents for 3s grace period with fade-out
  transition instead of instantly hiding them
- Transition activity labels to Completed state for 1.5s grace period
  on session idle instead of abrupt removal
- Eliminate message flash during thinking reclassification by folding
  trailing pending assistant messages into the thinking group
  optimistically when they follow existing thinking messages
- Stabilize ThinkingProcess isActive flag by checking for pending
  messages in the group rather than relying solely on array position
- Skip rendering empty pending messages inside ThinkingProcess steps

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 08:44:18 +02:00
David KayaandCopilot 1ac1cc1d47 fix: share single CopilotClient across workflow agents
Previously, CopilotAgentBundle.CreateAsync spawned a separate
CopilotClient (and thus a separate Copilot CLI process) for each
agent in a workflow. When multiple CLI processes started concurrently,
their auto-login mechanisms could race on token refresh, causing
subsequent agents to fail with 'Session was not created with
authentication info or custom provider'.

Share one CopilotClient across all agents in the bundle. Each agent
still gets its own CopilotSession on the shared client. The bundle
owns the client and disposes it after all agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 08:15:56 +02:00
David KayaandCopilot bf72315735 feat: enable all workflow types for scratchpad sessions
Scratchpad sessions were hardcoded to only use single-mode workflows.
The handleCreateScratchpad handler filtered for orchestrationMode === 'single'
and auto-selected the first match without any user choice.

Now clicking 'New Scratchpad' opens the WorkflowPicker (same as regular
sessions), letting users choose from all available workflows including
sequential, concurrent, handoff, and group-chat. When only one workflow
exists, it still auto-creates for a seamless single-click experience.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 08:00:40 +02:00
David KayaandCopilot 59170a794d feat: add workflow picker for new session creation
When creating a new session with >1 workflow available, a searchable
popover appears letting users choose which workflow to use. Workflows
are grouped by orchestration mode with mode badges, agent counts,
and favorite indicators. Keyboard navigation (↑↓ Enter Escape) and
fuzzy search are supported.

If only one workflow exists, session creation works immediately
without showing the picker. Scratchpad auto-selects as before.

Wired into all session creation paths:
- Sidebar 'New Session' button
- Ctrl/Cmd+N keyboard shortcut
- Command palette 'New Session' action

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:57:06 +02:00
David KayaandCopilot 3564df78d3 fix: always replace builtin workflows with canonical definitions
The merge logic was preserving stale persisted versions of builtin
workflows, only overlaying orchestrationMode. This meant edge fixes
(like the handoff loop marking) never reached users who already had
the old version saved.

Builtin workflows are now fully replaced on load so fixes propagate
automatically. Custom (non-builtin) workflows are preserved as-is.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:41:00 +02:00
David KayaandCopilot a68c9ee837 fix: mark handoff triage-to-specialist edges as loop edges
The forward edges from triage to specialists participate in cycles
(triage→specialist + specialist→triage), so they must be marked with
isLoop, condition, and maxIterations to pass validation.

Fixes the Handoff Support Flow template and scaffoldGraphForMode()
for handoff mode. Updates the corresponding test expectation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:37:34 +02:00
David KayaandCopilot 5ac6980b3f fix: prevent settings panel from collapsing graph canvas
The center column was a flex column with the canvas as flex-1 min-h-0
and the settings panel as shrink-0. When settings content grew tall
(especially with the orchestration mode panel expanded), the canvas
collapsed to zero height.

Switch the center column to a scrollable container with the canvas
having a guaranteed minimum height via clamp(360px, 50vh, 100%).
Settings flow naturally below and the whole column scrolls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:33:59 +02:00
David KayaandCopilot f4cb4c768b feat: add frontend orchestration mode UI to workflow designer
- Add OrchestrationModePanel with mode cards for all 5 modes (single,
  sequential, concurrent, handoff, group-chat)
- Add handoff settings panel: triage agent selector, tool-call
  filtering, return-to-previous toggle, custom instructions
- Add group-chat settings panel: selection strategy display, max
  rounds input
- Add auto-inference indicator when mode is not explicitly set
- Add scaffold dialog when changing mode with existing agents
- Add mode badge (top-left pill) on workflow graph canvas
- Wire orchestration mode panel into WorkflowSettingsPanel above
  general settings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:30:01 +02:00
David KayaandCopilot e265714225 feat: add backend orchestration mode support
Add first-class backend support for workflow orchestration modes,
including mode settings, graph scaffolding, mode-aware validation,
and real Agent Framework handoff/group-chat execution paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:18:51 +02:00
David KayaandCopilot 805e369b67 refactor: remove legacy patterns system, unify on workflows
Remove the entire patterns domain model, IPC channels, sidecar services,
renderer components, and tests. Sessions now bind exclusively to workflows
via workflowId. Builtin workflows replace builtin patterns.

Backend:
- Make AgentNodeConfig standalone (no longer extends PatternAgentDefinition)
- Add WorkflowOrchestrationMode and WorkflowExecutionDefinition
- Create builtin workflows (single-agent, sequential, concurrent, handoff, group-chat)
- Rewrite session model config helpers for workflow-only
- Remove pattern IPC channels, handlers, and preload bindings
- Merge createSession/createWorkflowSession into single method
- Remove sidecar PatternGraphResolver, PatternValidator, pattern DTOs
- Add workspace migration for legacy sessions (patternId -> workflowId)

Frontend:
- Delete PatternEditor, pattern-graph components, patternGraph lib
- Delete NewSessionModal (session creation uses workflows directly)
- Remove PatternsSection from SettingsPanel
- Update App.tsx, ChatPane, ActivityPanel, Sidebar, RunTimeline,
  AgentConfigFields, InlinePills, sessionActivity to use workflow types
- Delete pattern.ts domain module

78 files changed across backend and frontend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 22:37:00 +02:00
David KayaandCopilot 69d0804161 refactor: remove pattern domain and migrate renderer to workflow-only
- Delete src/shared/domain/pattern.ts and all pattern-specific renderer
  components (PatternEditor, NewSessionModal, pattern-graph directory,
  patternGraph lib) and their tests
- Migrate all renderer imports from @shared/domain/pattern to
  @shared/domain/workflow (ReasoningEffort, reasoningEffortOptions,
  WorkflowOrchestrationMode, AgentNodeConfig, WorkflowDefinition)
- Update App.tsx: remove workflowToPattern bridge, createDraftPattern,
  NewSessionModal; sessions now created directly with default workflow
- Update ChatPane, ActivityPanel, Sidebar to accept WorkflowDefinition
  instead of PatternDefinition and derive agents/mode from workflow
- Update SettingsPanel: remove PatternsSection nav item, pattern editing
  state, and pattern-related props; update text references
- Update RunTimeline and modeAccent/modeVisuals records to use
  WorkflowOrchestrationMode (drop magentic mode entry)
- Update sessionActivity.ts to use generic agent interface

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 22:34:42 +02:00
David KayaandCopilot 059714326a fix(security): harden YAML parsing, path traversal, and markdown links
- workflowSerialization: restrict YAML parser to core schema with no custom tags
- gitService: validate file paths stay within project directory before I/O
- chatMarkdown: allowlist URL protocols (https, http, mailto, anchors)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 23:43:06 +02:00
David KayaandCopilot b302ea5979 feat(workflows): replace basic templates with 8 rich hand-crafted templates
Replace auto-generated pattern-based templates with complex, real-world
workflow templates showcasing all node types and edge features:

- Code Review Pipeline: sequential chain with checkpointing
- Research & Summarize: 3 parallel agents via fan-out/fan-in
- Customer Support Triage: conditional routing with expression edges
- Content Creation Pipeline: writer/editor revision loop
- Multi-Agent Debate: proposer/critic debate loop
- Data Processing with Validation: invoke-function nodes, state scopes
- Approval Workflow: request-port for human-in-the-loop review
- Nested Workflow Orchestrator: inline sub-workflow composition

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 22:27:35 +02:00
David KayaandCopilot 81eb8f7c82 refactor(workflows): remove code-executor and align function node with Agent Framework
Remove the invented code-executor node type entirely. Rename
function-executor to invoke-function to match Agent Framework's
InvokeFunctionTool declarative action. Update InvokeFunctionConfig to
use functionName, arguments, requireApproval, and resultVariable
properties matching the upstream schema.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:59:16 +02:00
David KayaandCopilot e47835c1e8 fix(workflows): disable start/end palette buttons when node already exists
Prevents users from adding duplicate start or end nodes, which made the
workflow invalid with no way to recover since those node kinds are
protected from deletion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:37:01 +02:00
David KayaandCopilot 55df0c3b61 feat: implement Phase 5 workflow frontend features
- Template Gallery: category-filtered grid in WorkflowsSection with
  template cards showing name, description, category/source badges,
  and node/agent counts with 'Use Template' action
- Export/Import: dropdown export (YAML/Mermaid/DOT) and import
  (YAML/JSON) modals in WorkflowEditor header toolbar via new
  WorkflowExportImportPanel component
- Pattern-to-Workflow upgrade: ArrowUpRight icon button on each
  pattern row in PatternsSection calling upgradePatternToWorkflow
- Canvas polish: zoom in/out/fit-view controls panel at bottom-right
  of WorkflowGraphCanvas, zoom percentage display, Ctrl+A select-all
  keyboard shortcut, and selectionOnDrag enabled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:28:43 +02:00
David KayaandCopilot 4971dcf9fc feat(workflows): add phase 5 backend templates and export support
- add shared workflow template and serialization helpers for YAML, Mermaid, and DOT export
- add pattern-to-workflow upgrade and template application flows in AryxAppService
- persist built-in and custom workflow templates in workspace state and expose new IPC/preload methods
- cover the new backend slice with shared and app-service tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:11:59 +02:00
David KayaandCopilot f05ec8ac7f feat: add code-executor, function-executor, request-port inspectors and enhanced workflow settings
- Create CodeExecutorInspector with implementation directive editor,
  input/output type fields, and directive help callout
- Create FunctionExecutorInspector with built-in function selector
  and key-value parameter editor
- Create RequestPortInspector with port ID, request/response type
  fields, prompt editor, and info callout
- Wire all three inspectors into WorkflowGraphInspector routing,
  keeping PlaceholderNodeInspector as fallback for unknown kinds
- Enhance WorkflowSettingsPanel with OpenTelemetry and sensitive
  data filtering toggles, and a state scopes editor with initial
  values support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 20:36:39 +02:00
David KayaandCopilot 69ac454f29 feat(workflows): add phase 4 backend executor support
Add MVP code-executor and function-executor runtime support, request-port
bridging through Aryx user input, state-scope helpers, lockstep execution
mode handling, and validation/tests for new workflow node kinds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 20:22:20 +02:00
David KayaandCopilot 7a32c9c0a3 fix(workflows): remove stale Phase 2 placeholder text from node inspector
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:53:20 +02:00
David KayaandCopilot 871609db5b feat(workflows): add sub-workflow inspector, breadcrumb navigation, and reference UI
Phase 3 frontend: sub-workflow node inspector with reference/inline mode toggle,
breadcrumb drill-down navigation for nested workflows, read-only view for
referenced workflows, inline workflow editing with stack propagation,
sub-workflow label badges on canvas nodes, and referential integrity error
display on delete.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:44:02 +02:00
David KayaandCopilot 41e74c2fa9 feat(workflows): add sub-workflow backend support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:31:56 +02:00
David KayaandCopilot ea9444ddac feat(workflows): add condition editor, loop controls, and edge visualization
Phase 2 frontend: visual condition editor with property rule builder, expression
input, message-type selector, loop toggle with max iterations, self-loop edge
rendering, conditional/loop edge badges, and per-edge validation display.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:08:11 +02:00
David KayaandCopilot 8f6830dca1 feat(workflows): add conditional edges and loop validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 18:57:33 +02:00
David KayaandCopilot f011311514 feat: add Workflow Designer frontend (Phase 1)
Implement the visual workflow editor UI with ReactFlow canvas,
node palette, inspector panel, and full SettingsPanel integration.

New files:
- workflowGraph.ts: graph utilities (layout, connection rules, edges)
- WorkflowGraphNodes.tsx: custom ReactFlow nodes per workflow kind
- WorkflowGraphCanvas.tsx: ReactFlow canvas with drag, connect, layout
- WorkflowGraphInspector.tsx: node/edge inspector with agent config
- WorkflowNodePalette.tsx: categorized node palette for adding nodes
- WorkflowEditor.tsx: full-screen editor with validation and settings

Modified files:
- SettingsPanel.tsx: add workflows section, nav item, editing state
- App.tsx: wire workflow CRUD props and createDraftWorkflow factory

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 17:53:33 +02:00
David KayaandCopilot 19a764d297 feat(workflows): Phase 1 backend — WorkflowDefinition model, persistence, IPC, sidecar execution
Introduce WorkflowDefinition as a first-class entity alongside PatternDefinition.

Shared domain:
- src/shared/domain/workflow.ts: new WorkflowDefinition, WorkflowGraph,
  WorkflowNode/Edge/Config types, normalization, TS-side validation, and
  buildWorkflowExecutionPattern (synthetic pattern bridge for session plumbing)
- workspace.ts: add workflows[] and selectedWorkflowId
- session.ts: add optional workflowId to SessionRecord
- runTimeline.ts: add optional workflowId/workflowName to run records
- sessionLibrary.ts: workflow-aware session search

IPC / main process:
- contracts/channels.ts, contracts/ipc.ts: workflow CRUD channels
- preload/index.ts: expose saveWorkflow, deleteWorkflow, createWorkflowSession
- ipc/registerIpcHandlers.ts: register workflow handlers
- persistence/workspaceRepository.ts: load/save/normalize workflows
- AryxAppService.ts: saveWorkflow, deleteWorkflow, createWorkflowSession,
  resolveSessionExecutionDefinition, workflow-aware turn/run plumbing

Sidecar protocol:
- contracts/sidecar.ts: ValidateWorkflowCommand, WorkflowValidationEvent,
  optional workflow on RunTurnCommand
- sidecarProcess.ts: validate-workflow command dispatch and event handling
- Contracts/ProtocolModels.cs: workflow DTOs and validate-workflow DTOs
- Services/SidecarProtocolHost.cs: validate-workflow command handler
- Services/CopilotWorkflowRunner.cs: workflow-aware build and checkpoint path
- Services/WorkflowValidator.cs: graph validation (connectivity, start/end,
  fan-out/in arity, path reachability)
- Services/WorkflowRunner.cs: WorkflowDefinitionDto -> Agent Framework Workflow
  builder (direct, fan-out, fan-in edges; agent executor binding)

Project: bump both csproj from net9.0 to net10.0

Tests:
- tests/shared/workflow.test.ts: TS workflow validation and pattern synthesis
- tests/main/appServiceWorkflow.test.ts: saveWorkflow / createWorkflowSession
- tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs: ValidateWorkflowCommand
- Fix two stale workspace fixtures missing workflows field

Validation: tsc clean, bun test 367/367 pass, dotnet test 244/244 pass, bun run build succeeds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 17:29:03 +02:00
David KayaandCopilot 4c3198a550 refactor: rename Orchestration to Workflows in settings nav
Align settings terminology with Agent Framework docs where 'Workflows'
is the top-level concept. 'Orchestration' now only appears as
'Orchestration Mode' in the pattern editor, correctly referring to the
specific mode (sequential, concurrent, etc.) within a workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 17:39:41 +02:00
David KayaandCopilot c0aaffa39f feat: allow deletion of built-in orchestration patterns
Remove the restriction that prevented users from deleting built-in
patterns (those with IDs prefixed 'pattern-'). Built-in patterns are
intended as starting points, not permanent fixtures.

Changes:
- Add deletedBuiltinPatternIds field to WorkspaceState to track which
  built-in patterns the user has removed
- Update mergePatterns to skip deleted built-ins during workspace load,
  preventing them from being re-added
- Remove the isBuiltinPattern guard from AryxAppService.deletePattern;
  when a built-in is deleted, its ID is recorded in the tracking list
- Enable the delete button in SettingsPanel and PatternEditor for all
  patterns (keep the 'Built-in pattern' label for context)
- Add 3 tests covering built-in deletion, custom deletion, and
  selectedPatternId fallback

Users can still restore deleted built-ins via workspace reset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 17:31:49 +02:00
David KayaandCopilot e39fffaf3b feat: add workspace agents for reusable agent definitions across patterns
Introduce a workspace-level agent library that allows users to define
agents once and reference them from multiple orchestration patterns.

Domain model:
- Add WorkspaceAgentDefinition type with name, model, instructions, etc.
- Extend PatternAgentDefinition with optional workspaceAgentId and overrides
- Add resolution helpers that merge workspace agent base with per-pattern overrides
- Extend WorkspaceSettings with agents array and normalize on load

IPC & main process:
- Add saveWorkspaceAgent/deleteWorkspaceAgent IPC channels and handlers
- Resolve workspace agent references in buildEffectivePattern before
  sending to sidecar (no C# changes needed)

Settings UI:
- Add 'Agents' tab under Orchestration in the Settings panel
- Create WorkspaceAgentEditor component using ToolingEditorShell
- Show usage count (which patterns reference each agent)

Pattern editor integration:
- Add agent picker dropdown: 'New inline agent' or 'From library'
- Show linked badge (chain icon) on referenced agent graph nodes
- Show linked workspace agent banner in the inspector
- Add 'Save to Agent Library' action to promote inline agents
- Add 'Unlink' action to convert referenced agents back to inline

Tests:
- Add unit tests for resolution helpers (resolvePatternAgent,
  resolvePatternAgents, findWorkspaceAgentUsages, normalize)
- Update existing tooling test for new agents field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 17:09:54 +02:00
David KayaandCopilot 10316a2871 fix: strip reasoning effort on prompt model override, show actual model badge
When a prompt file overrides the turn model, the original agent's
reasoning effort was carried through even when the target model does
not support it. This caused session.create failures for models like
Claude Opus 4.5 which reject reasoning effort configuration.

Now: if the target model's reasoning capabilities are unknown
(supportedReasoningEfforts undefined) or it's an unresolved model
reference, reasoning effort is stripped entirely.

Also adds a model override badge on assistant messages when the run
used a different model than the session's primary agent. The badge
reads the actual model from the run record (SessionRunRecord.agents)
rather than inferring from the prompt invocation metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 16:30:32 +02:00
David KayaandCopilot a18803758b feat: phase 3 frontend — model badges, argument-hint composer, ancestor path display
Surface prompt model and argumentHint metadata across the UI:

- InlinePromptPill: send model in promptInvocation, show model badge
  in dropdown, show argumentHint preview, support armed-prompt state
  for prompts with argumentHint (prompt stays pinned, hint becomes
  composer placeholder, user types additional context before sending)
- ChatPane: armed-prompt state management, argumentHint-driven
  placeholder, send button enabled when prompt is armed, clear armed
  state on session change
- PromptInvocationChrome: model badge in transcript, parent-repo badge
  and shortened source path display for ancestor-relative paths
- ProjectSettingsPanel: model and argumentHint on prompt cards,
  parent-repo badge on instructions and prompts with ancestor paths,
  .claude/rules mentioned in instructions description and empty state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 12:53:38 +02:00
David KayaandCopilot 6eadb36c10 feat: add phase 3 prompt customization backend
- parse prompt model and argument-hint metadata and persist model on prompt invocations
- expand markdown-linked file context in scanned prompt and instruction bodies
- discover .claude/rules instructions and support Claude-style paths metadata
- discover customization roots up to the nearest parent repository and watch them for changes
- apply per-turn prompt model overrides before sidecar execution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 12:21:59 +02:00
David KayaandCopilot bcbdd2ef29 feat: wire structured prompt invocation through renderer
- Update InlinePromptPill to build ProjectPromptInvocation from
  selected prompt file and send it via onSubmit instead of pasting
  raw resolved text as chat content
- Thread promptInvocation through ChatPane.onSend and App.tsx to
  the sendSessionMessage IPC call
- Add PromptInvocationChrome component for user messages that were
  triggered by a prompt file, showing prompt name, agent badge,
  tool count, source path, and expandable resolved body
- Surface prompt agent and tools metadata in InlinePromptPill
  dropdown list items and ProjectSettingsPanel PromptCard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:49:45 +02:00
David KayaandCopilot 5d69d9d855 feat: add structured prompt invocation backend
- parse prompt tools metadata and carry structured promptInvocation payloads
- store prompt invocation metadata on trigger messages for replay-safe reruns
- route prompt agents through per-turn plan or Copilot agent overrides
- restrict prompt-scoped tools in sidecar session configuration
- auto-rescan project customization files with debounced watchers
- document the new customization watcher and prompt invocation flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:41:18 +02:00
David KayaandCopilot aa7830f01a feat: update project settings UI for expanded instruction discovery
- Show application mode badges (always/file/task/manual) on instruction cards
- Display instruction name, description, and applyTo glob when present
- Update section descriptions to reflect recursive scanning paths
- Update empty states to list all discovery locations
- Add InstructionModeBadge component for visual mode indicators

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:21:10 +02:00
David KayaandCopilot 36b37e8915 feat: expand repo customization discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:14:39 +02:00
David Kaya dc69f8bf04 chore: bump version to 0.0.21 2026-04-01 20:57:05 +02:00
David KayaandCopilot 6f7cf60aa9 fix: render pending messages with markdown formatting
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 20:12:59 +02:00
David KayaandCopilot 423d45fa1b fix: use active agent for streaming author fallback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:40:46 +02:00
David KayaandCopilot 13bcc44f1a feat: add handoff workflow checkpoint recovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:28:05 +02:00
David KayaandCopilot 1ceb3d5669 fix: project copilot tool results into workflows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:17:47 +02:00
David KayaandCopilot 1dd13588a0 refactor: move handoff guidance into workflow builder
Move handoff routing and ownership rules out of per-agent system
prompts and into the Agent Framework handoff builder guidance.

This keeps AgentInstructionComposer focused on Aryx-owned system
prompt content while letting WithHandoffInstructions supply the
workflow-level handoff semantics.

- remove handoff-mode runtime guidance from AgentInstructionComposer
- expand HandoffWorkflowGuidance with the triage/specialist rules
- update tests to pin the new split of responsibilities

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:05:01 +02:00
David KayaandCopilot 235ddf7e56 refactor: pin workflow host options explicitly
Explicitly configure AIAgentHostOptions for the workflow modes that
host agents directly instead of relying on Agent Framework defaults.

- add a shared host-options factory that preserves Aryx's current
  behavior
- use custom sequential, concurrent, and round-robin group-chat
  builders so all host options are set intentionally
- add workflow-level tests asserting the configured host options

Keep EmitAgentResponseEvents disabled because Aryx still projects
streaming transcript state itself and enabling response events would
require a separate reconciliation change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:04:44 +02:00
David KayaandCopilot d7004ec2a9 fix: make handoff filtering explicit
Explicitly configure handoff workflows to use
HandoffToolCallFilteringBehavior.HandoffOnly instead of relying on
Agent Framework's current default.

This keeps normal tool-call history visible across handoffs while
still stripping handoff plumbing, and adds a regression test to pin
that behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:04:23 +02:00
David KayaandCopilot 0aed6240b9 feat: render workflow diagnostics in activity panel
Surface workflow-diagnostic session events (warnings and errors from
Agent Framework workflows) in the turn-event log of the Activity Panel.

- Add workflow-diagnostic case to formatTurnEventEntry with label/detail
  formatting that includes executor ID, subworkflow ID, exception type,
  and diagnostic message when present
- Add AlertTriangle icon for diagnostic events with error/warning color
- Add 5 tests covering executor-failed, workflow-warning, subworkflow-error,
  workflow-error, and missing diagnosticKind fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 18:54:21 +02:00
David KayaandCopilot 11b36827f5 feat: surface workflow diagnostics
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 18:50:56 +02:00
David Kaya b434dd86b4 chore: bump version to 0.0.20 2026-03-31 17:10:47 +02:00
David Kaya c2a691774a feat: add light mode screenshots 2026-03-31 17:04:35 +01:00
David KayaandCopilot 78949c5efd feat: render per-turn thinking tiles in chat pane
Replace the single aggregate thinking tile with per-turn thinking groups.
Instead of collecting all thinking messages into one flat array and
rendering a single ThinkingProcess before the last assistant message,
process session.messages in chronological order to produce interleaved
display items that naturally group consecutive thinking messages by turn.

Each turn now shows its own collapsible thinking tile placed inline
before its assistant response, with correct per-turn isActive state
and turnStartedAt from the matching run.

Also fixes a latent index-mismatch bug in getAssistantMessagePhase where
the visible-messages index was compared against the full session.messages
index, potentially hiding the Final badge when thinking messages existed.
Switched to ID-based comparison.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:53:28 +01:00
David KayaandCopilot fb3e80ec47 refactor: move git panel to tabbed bottom panel alongside terminal
Replace the narrow 256px sidebar git section with a shared tabbed
bottom panel that hosts both Terminal and Git as peer tabs. This gives
git operations full horizontal width for file paths, diff previews,
branch lists, and commit history.

- Create BottomPanel with shared resize handle, tab bar, and content
  switching (terminal stays mounted via CSS visibility when inactive)
- Simplify TerminalPanel by extracting resize/close to BottomPanel
- Add InlineGitPill toggle in composer footer next to Terminal pill
- Wire tab switching: clicking active tab closes panel, clicking
  inactive tab switches to it
- Remove GitPanel from ActivityPanel sidebar
- Update ARCHITECTURE.md to describe tabbed bottom panel layout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:51:51 +01:00
David KayaandCopilot 5a71539705 feat: add git workflow frontend components
- RunChangeSummaryCard: post-run change review card with expandable
  file diffs, origin badges, selective discard with confirmation,
  and commit composer launch button
- CommitComposer: slide-over panel for staging files, editing AI-
  suggested commit messages, conventional commit type selection,
  and commit with optional push
- GitPanel: embedded activity panel section with branch management,
  push/pull/fetch operations, working tree inspection with inline
  diffs, and recent commit history
- Wire RunChangeSummaryCard into RunTimeline after completed runs
- Wire CommitComposer as overlay in App.tsx with state management
- Wire GitPanel into ActivityPanel for non-scratchpad sessions
- Update ARCHITECTURE.md with frontend git integration description

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:43:45 +01:00
David KayaandCopilot 906433f408 feat: add git workflow backend operations
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:43:13 +01:00
David KayaandCopilot 44d0ab07db feat: add git integration enhancements (phase 1)
Real-time git awareness:
- Auto-refresh git context on window focus and after run completion
- Periodic background polling (60s interval, configurable via settings)
- Pre-run working tree snapshot capture for project-backed runs
- Debounced refresh scheduling to coalesce rapid triggers

Backend (main process):
- GitService.captureWorkingTreeSnapshot() with per-file metadata
- Enriched parseWorkingTree() producing ProjectGitWorkingTreeFile entries
- AryxAppService: scheduleProjectGitRefresh(), periodic timer, focus hook
- preRunGitSnapshot persisted on SessionRunRecord with normalization

Frontend (renderer):
- Settings toggle for auto-refresh (General > Git section)
- RunTimeline: git baseline indicator showing branch and change summary
- Enhanced GitContextBadge tooltip with change breakdown details
- ChatPane: enriched git tooltip with staged/modified/untracked counts
- New IPC channel: setGitAutoRefreshEnabled

Tests: 8 new tests covering snapshot capture, refresh scheduling,
scratchpad skip behavior, and auto-refresh setting persistence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:37:02 +01:00
David KayaandCopilot 49933f218b feat(website): add light mode with theme toggle
- Add [data-theme='light'] CSS variable overrides for warm parchment surface palette
- Add sun/moon theme toggle button in nav (desktop + mobile)
- Inline FOUC-prevention script in <head> reading localStorage/prefers-color-scheme
- Screenshot <img> elements swap between dark/light variants via data attributes
- Dynamic meta theme-color update and proper ARIA labels on toggle
- Adjust grain overlay, dot-grid, and gradient-text for light mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:35:17 +01:00
David Kaya 63acd9e87e Merge branch 'main' of https://github.com/davidkaya/aryx 2026-03-31 14:20:19 +02:00
David KayaandCopilot ebb506a359 feat: show platform-specific Copilot CLI install instructions when CLI is missing
Replace the generic 'Install the copilot CLI' one-liner with a rich,
platform-tabbed installation guide. Auto-detects macOS/Windows/Linux and
shows the recommended install command (Homebrew, WinGet, or install script)
plus alternatives (npm). Includes copy-to-clipboard, auth step, and a
refresh button.

- Extend platform.ts with isWindows, isLinux, detectedPlatform
- Add cliInstallInstructions.ts with per-platform install data
- Add CliInstallGuide component in settings/ with platform tabs
- Integrate into CopilotStatusCard for copilot-cli-missing state
- Enhance WelcomePane with CliMissingCard showing quick-start command

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 12:34:01 +02:00
Dávid KayaandGitHub b25da56f5e chore: Add Contributor Covenant Code of Conduct
This document outlines the standards of behavior for community members, including pledges, acceptable and unacceptable behaviors, enforcement responsibilities, and consequences for violations.
2026-03-31 11:41:20 +02:00
Dávid KayaandGitHub 30ff81009a chore: Change Dependabot update interval from weekly to daily 2026-03-31 11:31:59 +02:00
David Kaya 9a261780c6 chore: bump version to 0.0.19 2026-03-31 11:09:12 +02:00
David KayaandCopilot 9b7e4dd6e9 feat: format and syntax-highlight JSON arguments in approval popup
- Add deepParseJsonStrings utility to recursively unwrap JSON-encoded
  string values inside tool-call args before display
- Apply to McpDetail, CustomToolDetail, and HookDetail renderers
- Add lightweight regex-based JSON syntax highlighting (keys, strings,
  numbers, booleans, punctuation) in CollapsibleCode when content is JSON
- Remove unused Terminal import

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 10:43:28 +02:00
David KayaandCopilot 4bc2c327f7 fix: honor MCP server auto-approvals in hook flow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 10:11:15 +02:00
173 changed files with 28377 additions and 8328 deletions
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
+37 -25
View File
@@ -2,7 +2,7 @@
## What this system is
Aryx is a desktop workspace for Copilot-powered development work. It combines a persistent session model, project-aware context, reusable multi-agent orchestration patterns, optional external tooling, and live run visibility inside a single Electron application.
Aryx is a desktop workspace for Copilot-powered development work. It combines a persistent session model, project-aware context, reusable workflow orchestration, optional external tooling, and live run visibility inside a single Electron application.
At a high level, the architecture is built around one core idea:
@@ -23,7 +23,7 @@ The current architecture optimizes for:
- **persistent workspaces** rather than disposable chat threads
- **project-aware execution** with repository context and optional tooling
- **observable AI runs** with streamed output, activity, and history
- **extensible orchestration** so patterns, models, and tool integrations can evolve without collapsing boundaries
- **extensible orchestration** so workflows, models, and tool integrations can evolve without collapsing boundaries
## System context
@@ -55,8 +55,8 @@ flowchart LR
| --- | --- | --- | --- |
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
| Main process | Workspace mutation, persistence, git inspection, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
@@ -89,7 +89,7 @@ sequenceDiagram
R->>P: Invoke typed API
P->>M: IPC request
M->>M: Append user message
M->>M: Create run record and mark session running
M->>M: Capture pre-run git snapshot, create run record, and mark session running
M->>S: run-turn command
S->>C: Execute workflow
C-->>S: Partial output / tool activity / handoffs / input requests
@@ -97,7 +97,7 @@ sequenceDiagram
M-->>R: Push session events and workspace updates
C-->>S: Final messages or turn boundary
S-->>M: Completion or error
M->>M: Finalize run and persist state
M->>M: Finalize run, compute post-run git summary, refresh project git state, and persist state
M-->>R: Final workspace snapshot
```
@@ -108,7 +108,8 @@ This flow is important because it shows that Aryx is not architected as a simple
The durable state of the app is a **workspace**. The workspace contains:
- connected projects
- orchestration patterns
- workflows
- workflow templates
- sessions
- settings
- run history
@@ -124,36 +125,38 @@ Projects are the container for context. There are two kinds:
The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations.
Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `.github/agents/*.agent.md`, and `.github/prompts/*.prompt.md`. The main process owns that scan step and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler.
Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.claude/CLAUDE.md`, recursive `.github/instructions/**/*.instructions.md`, recursive `.claude/rules/**/*.md`, recursive `.github/agents/**/*.agent.md`, and recursive `.github/prompts/**/*.prompt.md`. The main process owns that scan step, walks parent directories up to the nearest `.git` root so nested workspace folders inherit repository-level customizations, strips Markdown front matter where supported, expands relative Markdown links into referenced file context, classifies instruction files by application mode (`always`, `file`, `task`, `manual`), and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. It also maintains lightweight filesystem watches over each discovered customization root plus existing `.github/` and `.claude/` subdirectories so customization changes can trigger debounced rescans and renderer updates without manual refreshes.
### Patterns
For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own.
Patterns describe how agents collaborate. The architecture supports:
### Workflows
Workflows describe how agents collaborate. The architecture supports:
- one-agent conversations
- sequential workflows
- concurrent responses
- handoff flows
- group chat style collaboration
- sequential execution
- concurrent fan-out / fan-in flows
- handoff-style routing
- group-chat style collaboration
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so handoff routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so workflow agent routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
Patterns now persist an explicit graph-backed topology alongside the flat agent list. Agent nodes carry stable agent ids, ordering, and layout metadata, while system nodes such as user input/output, distributor, collector, and orchestrator make mode-specific flow visible in the saved contract.
Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure visible in the saved contract.
That graph is now the execution contract for the sidecar: sequential order comes from the saved path, handoff routes come from directed graph edges, and concurrent/group-chat participant ordering can be derived from graph node metadata instead of hard-coded runtime assumptions.
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time.
The pattern editor renders an interactive graph canvas powered by React Flow (`@xyflow/react`). The canvas projects the authoritative `PatternGraph` into React Flow nodes and edges via a view-model layer (`src/renderer/lib/patternGraph.ts`). Users can drag nodes to reposition them, and in handoff mode can draw new agent-to-agent edges directly on the canvas. A right-side inspector panel shows the details of the selected node — system node metadata for system nodes, or the full agent configuration form (model, reasoning, instructions) for agent nodes. The mode selector, pattern metadata, approval checkpoints, and tool auto-approval settings remain below the graph as scrollable settings sections. The `syncPatternGraph()` adapter is still called when agents are added/removed or the mode changes, rebuilding the graph from the current state; direct graph edits (drag positions, handoff edges) are persisted without the adapter.
Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol.
### Sessions
A session is the working unit of the product. It binds together:
- a project
- a pattern
- a workflow
- a message history
- status and errors
- optional per-session tool selection
@@ -172,8 +175,9 @@ Each user turn becomes a **run**. A run is more than the final assistant output;
- which activity happened during the turn
- partial streaming output
- success or failure
- optional git baselines and post-run change summaries for project-backed execution
That run model is what enables the activity panel and historical timeline instead of forcing the user to infer execution from message text alone.
That run model is what enables the inline turn activity panel instead of forcing the user to infer execution from message text alone.
## Communication model
@@ -187,6 +191,8 @@ Typical examples:
- load workspace
- create session
- create a workflow from a template
- export or import a workflow definition
- send message
- update theme
- create or restart the integrated terminal
@@ -203,7 +209,7 @@ This is a structured stdio protocol used for:
- capability discovery
- on-demand account quota lookup
- pattern validation
- workflow validation
- run execution
- streaming partial output
- streaming agent activity
@@ -221,6 +227,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.
@@ -230,7 +238,9 @@ The same boundary also supports server-scoped sidecar commands that do not requi
For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy.
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.
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from always-on repo instructions plus formatted file-scoped and task-scoped `.instructions.md` / `.claude/rules` entries, while omitting manual-only instruction files from automatic injection. It also merges enabled discovered custom agent profiles into the primary workflow agent's Copilot configuration before sending the command across the stdio boundary. Prompt-file submissions can additionally attach a structured `promptInvocation` payload with prompt identity, resolved prompt body, optional `agent`, optional `model`, and optional `tools` metadata. The main process stores that prompt invocation metadata on the triggering user message so replay and regenerate flows can rebuild it, hydrates missing metadata from the scanned prompt definition, promotes `agent: plan` to a per-turn plan-mode override, applies per-turn prompt model overrides to the effective workflow before execution, and falls back to a lightweight transcript message instead of pasting the full prompt body into chat history. The sidecar then folds both the project instructions and prompt invocation into the final SDK system message, uses prompt agent metadata to override `SessionConfig.Agent`, and narrows available tools for that turn when prompt `tools` metadata is present.
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
@@ -290,7 +300,7 @@ Tooling is deliberately split into two levels:
- **global definitions** for MCP servers and LSP profiles
- **MCP tool discovery** — when MCP server configs declare wildcard tools (empty `tools` array), the main process probes each server directly via the MCP protocol `tools/list` method to discover available tools, using the same auth credentials Aryx manages for OAuth-protected servers
- **incremental probe progress** — MCP probing runs concurrently and publishes per-server progress through the pushed workspace snapshot, using the runtime-only `mcpProbingServerIds` field so the renderer can reflect in-flight discovery without persisting transient UI state
- **pattern defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
- **workflow defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
- **per-session overrides** for both tool enablement and tool auto-approval
This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety.
@@ -299,6 +309,8 @@ This lets the application treat tooling as reusable workspace capability while s
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the turn activity panel after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly.
### Execution observability
The architecture treats execution as observable by design:
@@ -306,7 +318,7 @@ The architecture treats execution as observable by design:
- partial output is streamed
- agent activity is surfaced
- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed
- runs are persisted as timeline history
- runs are surfaced inline as collapsible turn activity panels
- failures are represented explicitly
This improves trust and debuggability, especially for multi-agent workflows.
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
noble_pinhole.0g@icloud.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+4 -2
View File
@@ -18,7 +18,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
## Highlights
- **Multi-agent orchestration** — single, sequential, concurrent, handoff, and group-chat patterns with a visual graph editor.
- **Multi-agent orchestration** — single, sequential, concurrent, handoff, and group-chat patterns with a visual graph editor, reusable workflow templates, and workflow import/export.
- **Project-grounded** — attach local folders and repos so every conversation has real codebase context.
- **Live execution visibility** — watch agents think, delegate, call tools, and consume context in real time.
- **Persistent workspace** — sessions survive restarts. Search, pin, archive, branch, and return to past work.
@@ -53,11 +53,13 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
| Feature | Description |
|---------|-------------|
| Orchestration patterns | Single, sequential, concurrent, handoff, and group-chat agent flows |
| Workflow templates | Save workflows as reusable templates, bootstrap from built-ins, and upgrade patterns into workflows |
| Workflow import/export | Export workflows as YAML, Mermaid, or DOT and import normalized definitions from YAML or JSON |
| Visual pattern editor | Drag nodes, draw connections, and inspect each step in a graph view |
| Mid-turn steering | Send follow-up messages while an agent is running — input is injected immediately |
| Plan review & questions | Agents propose plans and ask clarifying questions before acting |
| Run timeline | Structured history of tool calls, delegations, hooks, and context usage |
| Copilot customization | Auto-discovers instructions, agent profiles, and prompt files from your repo |
| Copilot customization | Auto-discovers repo instructions (`copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, `.instructions.md`, `.claude/rules`), agent profiles, and prompt files across nested repo roots, expands Markdown-linked context, exposes prompt metadata (`tools`, `model`, `argument-hint`), then auto-rescans when those files change |
| Model & effort tuning | Choose models, adjust reasoning effort, and set interaction modes per session |
### Developer tooling
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aryx",
"version": "0.0.18",
"version": "0.0.22",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
@@ -1,19 +1,9 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Aryx.AgentHost.Contracts;
public sealed class PatternAgentDefinitionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Instructions { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string? ReasoningEffort { get; init; }
public PatternAgentCopilotConfigDto? Copilot { get; init; }
}
public sealed class PatternAgentCopilotConfigDto
public sealed class WorkflowAgentCopilotConfigDto
{
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
public string? Agent { get; init; }
@@ -22,46 +12,145 @@ public sealed class PatternAgentCopilotConfigDto
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
}
public sealed class PatternGraphPositionDto
public sealed class WorkflowPositionDto
{
public double X { get; init; }
public double Y { get; init; }
}
public sealed class PatternGraphNodeDto
public sealed class WorkflowNodeConfigDto
{
public string Kind { get; init; } = string.Empty;
public string? InputType { get; init; }
public string? OutputType { get; init; }
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Instructions { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string? ReasoningEffort { get; init; }
public WorkflowAgentCopilotConfigDto? Copilot { get; init; }
public string? WorkspaceAgentId { get; init; }
public string? Implementation { get; init; }
public string? FunctionRef { get; init; }
public IReadOnlyDictionary<string, JsonElement>? Parameters { get; init; }
public string? WorkflowId { get; init; }
public WorkflowDefinitionDto? InlineWorkflow { get; init; }
public string? PortId { get; init; }
public string? RequestType { get; init; }
public string? ResponseType { get; init; }
public string? Prompt { get; init; }
}
public sealed class WorkflowNodeDto
{
public string Id { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public PatternGraphPositionDto Position { get; init; } = new();
public string? AgentId { get; init; }
public string Label { get; init; } = string.Empty;
public WorkflowPositionDto Position { get; init; } = new();
public int? Order { get; init; }
public WorkflowNodeConfigDto Config { get; init; } = new();
}
public sealed class PatternGraphEdgeDto
public sealed class WorkflowConditionRuleDto
{
public string PropertyPath { get; init; } = string.Empty;
public string Operator { get; init; } = string.Empty;
public string Value { get; init; } = string.Empty;
}
public sealed class EdgeConditionDto
{
public string Type { get; init; } = string.Empty;
public string? TypeName { get; init; }
public string? Expression { get; init; }
public string? Combinator { get; init; }
public IReadOnlyList<WorkflowConditionRuleDto> Rules { get; init; } = [];
}
public sealed class FanOutConfigDto
{
public string Strategy { get; init; } = "broadcast";
public string? PartitionExpression { get; init; }
}
public sealed class WorkflowEdgeDto
{
public string Id { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public string Target { get; init; } = string.Empty;
public string Kind { get; init; } = "direct";
public EdgeConditionDto? Condition { get; init; }
public string? Label { get; init; }
public FanOutConfigDto? FanOutConfig { get; init; }
public bool? IsLoop { get; init; }
public int? MaxIterations { get; init; }
}
public sealed class PatternGraphDto
public sealed class WorkflowGraphDto
{
public IReadOnlyList<PatternGraphNodeDto> Nodes { get; init; } = [];
public IReadOnlyList<PatternGraphEdgeDto> Edges { get; init; } = [];
public IReadOnlyList<WorkflowNodeDto> Nodes { get; init; } = [];
public IReadOnlyList<WorkflowEdgeDto> Edges { get; init; } = [];
}
public sealed class PatternDefinitionDto
public sealed class WorkflowCheckpointSettingsDto
{
public bool Enabled { get; init; }
}
public sealed class WorkflowTelemetrySettingsDto
{
public bool? OpenTelemetry { get; init; }
public bool? SensitiveData { get; init; }
}
public sealed class WorkflowStateScopeDto
{
public string Name { get; init; } = string.Empty;
public string? Description { get; init; }
public IReadOnlyDictionary<string, JsonElement>? InitialValues { get; init; }
}
public sealed class HandoffModeSettingsDto
{
public string ToolCallFiltering { get; init; } = "handoff-only";
public bool ReturnToPrevious { get; init; }
public string? HandoffInstructions { get; init; }
public string? TriageAgentNodeId { get; init; }
}
public sealed class GroupChatModeSettingsDto
{
public string SelectionStrategy { get; init; } = "round-robin";
public int MaxRounds { get; init; } = 5;
}
public sealed class OrchestrationModeSettingsDto
{
public HandoffModeSettingsDto? Handoff { get; init; }
public GroupChatModeSettingsDto? GroupChat { get; init; }
}
public sealed class WorkflowSettingsDto
{
public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new();
public string ExecutionMode { get; init; } = "off-thread";
public string? OrchestrationMode { get; init; }
public OrchestrationModeSettingsDto? ModeSettings { get; init; }
public int? MaxIterations { get; init; }
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
public IReadOnlyList<WorkflowStateScopeDto> StateScopes { get; init; } = [];
public WorkflowTelemetrySettingsDto? Telemetry { get; init; }
}
public sealed class WorkflowDefinitionDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Mode { get; init; } = string.Empty;
public string Availability { get; init; } = "available";
public string? UnavailabilityReason { get; init; }
public int MaxIterations { get; init; }
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
public PatternGraphDto? Graph { get; init; }
public bool? IsFavorite { get; init; }
public WorkflowGraphDto Graph { get; init; } = new();
public WorkflowSettingsDto Settings { get; init; } = new();
public string CreatedAt { get; init; } = string.Empty;
public string UpdatedAt { get; init; } = string.Empty;
}
@@ -98,11 +187,13 @@ public sealed class ChatMessageAttachmentDto
public string? DisplayName { get; init; }
}
public sealed class PatternValidationIssueDto
public sealed class WorkflowValidationIssueDto
{
public string Level { get; init; } = "error";
public string? Field { get; init; }
public string Message { get; init; } = string.Empty;
public string? NodeId { get; init; }
public string? EdgeId { get; init; }
}
public sealed class SidecarModeCapabilityDto
@@ -172,9 +263,10 @@ public class SidecarCommandEnvelope
public sealed class DescribeCapabilitiesCommandDto : SidecarCommandEnvelope;
public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
{
public PatternDefinitionDto Pattern { get; init; } = new();
public WorkflowDefinitionDto Workflow { get; init; } = new();
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
}
public sealed class RunTurnCommandDto : SidecarCommandEnvelope
@@ -185,9 +277,24 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
public string Mode { get; init; } = "interactive";
public string MessageMode { get; init; } = "enqueue";
public string? ProjectInstructions { get; init; }
public PatternDefinitionDto Pattern { get; init; } = new();
public WorkflowDefinitionDto Workflow { get; init; } = new();
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
public RunTurnToolingConfigDto? Tooling { get; init; }
public WorkflowCheckpointResumeDto? ResumeFromCheckpoint { get; init; }
}
public sealed class RunTurnPromptInvocationDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string SourcePath { get; init; } = string.Empty;
public string ResolvedPrompt { get; init; } = string.Empty;
public string? Description { get; init; }
public string? Agent { get; init; }
public string? Model { get; init; }
public IReadOnlyList<string>? Tools { get; init; }
}
public sealed class CancelTurnCommandDto : SidecarCommandEnvelope
@@ -311,9 +418,9 @@ public sealed class CapabilitiesEventDto : SidecarEventDto
public SidecarCapabilitiesDto Capabilities { get; init; } = new();
}
public sealed class PatternValidationEventDto : SidecarEventDto
public sealed class WorkflowValidationEventDto : SidecarEventDto
{
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
}
public sealed class TurnDeltaEventDto : SidecarEventDto
@@ -349,6 +456,7 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string? SourceAgentName { get; init; }
public string? ToolName { get; init; }
public string? ToolCallId { get; init; }
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
public IReadOnlyList<ToolCallFileChangeDto>? FileChanges { get; init; }
}
@@ -488,6 +596,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 +721,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;
@@ -10,14 +10,14 @@ internal static class AgentIdentityResolver
private const string GenericAssistantIdentifier = "assistant";
public static bool TryResolveKnownAgentIdentity(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
string? agentIdentifier,
out AgentIdentity agent)
{
agent = default;
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier)
?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier);
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier)
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier);
if (match is null)
{
return false;
@@ -28,12 +28,12 @@ internal static class AgentIdentityResolver
}
public static bool TryResolveObservedAgentIdentity(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
string? agentIdentifier,
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent))
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent))
{
return true;
}
@@ -49,13 +49,13 @@ internal static class AgentIdentityResolver
}
public static AgentIdentity ResolveAgentIdentity(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
string? agentId,
string? agentName)
{
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId)
?? FindKnownAgent(pattern, agentName)
?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName);
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId)
?? FindKnownAgent(workflow, agentName)
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName);
return match is not null
? ToAgentIdentity(match)
@@ -63,16 +63,16 @@ internal static class AgentIdentityResolver
}
public static string ResolveDisplayAuthorName(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
string? primaryIdentifier,
string? fallbackIdentifier = null)
{
if (TryResolveKnownAgentIdentity(pattern, primaryIdentifier, out AgentIdentity primaryAgent))
if (TryResolveKnownAgentIdentity(workflow, primaryIdentifier, out AgentIdentity primaryAgent))
{
return primaryAgent.AgentName;
}
if (TryResolveKnownAgentIdentity(pattern, fallbackIdentifier, out AgentIdentity fallbackAgent))
if (TryResolveKnownAgentIdentity(workflow, fallbackIdentifier, out AgentIdentity fallbackAgent))
{
return fallbackAgent.AgentName;
}
@@ -98,26 +98,23 @@ internal static class AgentIdentityResolver
StringComparison.Ordinal);
}
private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias(
PatternDefinitionDto pattern,
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
WorkflowDefinitionDto workflow,
params string?[] agentIdentifiers)
{
return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
? pattern.Agents[0]
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
? agentNodes[0]
: null;
}
private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate)
{
return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate));
}
private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent)
{
return new AgentIdentity(
agent.Id,
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name);
}
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
=> new(agent.GetAgentId(), agent.GetAgentName());
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
{
@@ -131,22 +128,24 @@ internal static class AgentIdentityResolver
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
}
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
if (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase)
|| string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase))
string agentId = agent.GetAgentId();
string agentName = agent.GetAgentName();
if (string.Equals(agentId, candidate, StringComparison.OrdinalIgnoreCase)
|| string.Equals(agentName, candidate, StringComparison.OrdinalIgnoreCase))
{
return true;
}
string normalizedCandidate = NormalizeComparisonKey(candidate);
string normalizedId = NormalizeComparisonKey(agent.Id);
string normalizedName = NormalizeComparisonKey(agent.Name);
string normalizedId = NormalizeComparisonKey(agentId);
string normalizedName = NormalizeComparisonKey(agentName);
if (normalizedCandidate.Length == 0)
{
return false;
@@ -5,15 +5,17 @@ namespace Aryx.AgentHost.Services;
internal static class AgentInstructionComposer
{
public static string Compose(
PatternDefinitionDto pattern,
PatternAgentDefinitionDto agent,
WorkflowDefinitionDto workflow,
WorkflowNodeDto agentNode,
int agentIndex,
string workspaceKind = "project",
string interactionMode = "interactive",
string? projectInstructions = null)
string? projectInstructions = null,
RunTurnPromptInvocationDto? promptInvocation = null)
{
string baseInstructions = agent.Instructions.Trim();
string baseInstructions = agentNode.Config.Instructions.Trim();
string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty;
string promptInvocationInstructions = FormatPromptInvocation(promptInvocation);
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
? """
You are operating in scratchpad mode.
@@ -32,7 +34,7 @@ internal static class AgentInstructionComposer
"""
: string.Empty;
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase))
if (string.Equals(workflow.Settings.OrchestrationMode, "group-chat", StringComparison.OrdinalIgnoreCase))
{
string groupChatGuidance = agentIndex == 0
? """
@@ -48,30 +50,21 @@ internal static class AgentInstructionComposer
Focus on refining the answer already in progress.
""";
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
return JoinInstructionBlocks(
baseInstructions,
repositoryInstructions,
promptInvocationInstructions,
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,
promptInvocationInstructions,
workspaceGuidance,
planModeGuidance);
}
private static string JoinInstructionBlocks(params string[] blocks)
@@ -80,4 +73,52 @@ internal static class AgentInstructionComposer
"\n\n",
blocks.Where(block => !string.IsNullOrWhiteSpace(block)).Select(block => block.Trim()));
}
private static string FormatPromptInvocation(RunTurnPromptInvocationDto? promptInvocation)
{
string? resolvedPrompt = promptInvocation?.ResolvedPrompt?.Trim();
if (string.IsNullOrWhiteSpace(resolvedPrompt))
{
return string.Empty;
}
List<string> lines =
[
"The current turn was started from a repository prompt file.",
"Treat the prompt body below as the task directive for this turn rather than as prior user chat history.",
$"Source: {promptInvocation!.SourcePath.Trim()}",
$"Name: {promptInvocation.Name.Trim()}"
];
if (!string.IsNullOrWhiteSpace(promptInvocation.Description))
{
lines.Add($"Description: {promptInvocation.Description.Trim()}");
}
if (!string.IsNullOrWhiteSpace(promptInvocation.Agent))
{
lines.Add($"Agent: {promptInvocation.Agent.Trim()}");
}
if (!string.IsNullOrWhiteSpace(promptInvocation.Model))
{
lines.Add($"Model: {promptInvocation.Model.Trim()}");
}
if (promptInvocation.Tools is not null)
{
List<string> toolNames = promptInvocation.Tools
.Where(tool => !string.IsNullOrWhiteSpace(tool))
.Select(tool => tool.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
lines.Add(toolNames.Count > 0
? $"Tools: {string.Join(", ", toolNames)}"
: "Tools: none");
}
lines.Add("Prompt instructions:");
lines.Add(resolvedPrompt);
return string.Join("\n", lines);
}
}
@@ -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)
@@ -1,16 +1,22 @@
using System.Threading;
using GitHub.Copilot.SDK;
using System.Linq;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.GitHub.Copilot;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotAgentBundle : IAsyncDisposable
{
private static readonly string[] RequiredPromptTools =
[
"ask_user",
"report_intent",
"task_complete"
];
private const string HandoffToolPrefix = "handoff_to_";
private readonly List<IAsyncDisposable> _disposables = [];
internal CopilotAgentBundle(IReadOnlyList<AIAgent> agents, bool hasConfiguredHooks)
@@ -25,9 +31,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
public static async Task<CopilotAgentBundle> CreateAsync(
RunTurnCommandDto command,
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
Func<PatternAgentDefinitionDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
Func<WorkflowNodeDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
Func<WorkflowNodeDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
Action<WorkflowNodeDto, SessionEvent>? onSessionEvent,
CancellationToken cancellationToken)
{
List<IAsyncDisposable> disposables = [];
@@ -46,11 +52,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
disposables.Add(toolingBundle);
}
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
{
CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
// Share a single CopilotClient across all agents to avoid spawning
// multiple CLI processes that race on token refresh during auto-login.
CopilotClient sharedClient = new(clientOptions);
await sharedClient.StartAsync(cancellationToken).ConfigureAwait(false);
IReadOnlyList<WorkflowNodeDto> agentNodes = command.Workflow.GetAllAgentNodes(command.WorkflowLibrary);
foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index)))
{
SessionConfig sessionConfig = CreateSessionConfig(
command,
definition,
@@ -62,19 +71,23 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
hookCommandRunner);
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
AryxCopilotAgent agent = new(
client,
sharedClient,
sessionConfig,
ownsClient: true,
id: definition.Id,
name: definition.Name,
description: definition.Description);
ownsClient: false,
id: definition.GetAgentId(),
name: definition.GetAgentName(),
description: NormalizeOptionalString(definition.Config.Description));
agents.Add(agent);
disposables.Add(agent);
}
// The bundle owns the shared client — disposed after all agents.
disposables.Add(sharedClient);
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
bundle._disposables.AddRange(disposables);
return bundle;
@@ -82,7 +95,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
internal static SessionConfig CreateSessionConfig(
RunTurnCommandDto command,
PatternAgentDefinitionDto definition,
WorkflowNodeDto definition,
int agentIndex,
PermissionRequestHandler? onPermissionRequest = null,
UserInputHandler? onUserInputRequest = null,
@@ -90,21 +103,20 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
ResolvedHookSet? configuredHooks = null,
IHookCommandRunner? hookCommandRunner = null)
{
// Let the Copilot SDK allocate session IDs. Explicit custom SessionId values currently
// cause turns to complete without assistant output, even for simple single-agent prompts.
return new SessionConfig
{
Model = definition.Model,
ReasoningEffort = definition.ReasoningEffort,
Model = definition.Config.Model,
ReasoningEffort = definition.Config.ReasoningEffort,
SystemMessage = new SystemMessageConfig
{
Content = AgentInstructionComposer.Compose(
command.Pattern,
command.Workflow,
definition,
agentIndex,
command.WorkspaceKind,
command.Mode,
command.ProjectInstructions),
command.ProjectInstructions,
command.PromptInvocation),
},
WorkingDirectory = command.ProjectPath,
OnPermissionRequest = onPermissionRequest,
@@ -112,11 +124,11 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
OnEvent = onSessionEvent,
Streaming = true,
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
CustomAgents = CreateCustomAgents(definition.Config.Copilot?.CustomAgents),
Agent = ResolveEffectiveAgent(definition.Config.Copilot?.Agent, command.PromptInvocation),
SkillDirectories = CreateStringList(definition.Config.Copilot?.SkillDirectories),
DisabledSkills = CreateStringList(definition.Config.Copilot?.DisabledSkills),
InfiniteSessions = CreateInfiniteSessions(definition.Config.Copilot?.InfiniteSessions),
};
}
@@ -136,6 +148,29 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
}
}
internal static void ApplyPromptInvocation(
SessionConfig sessionConfig,
RunTurnPromptInvocationDto? promptInvocation)
{
IReadOnlyList<string>? allowedTools = NormalizeToolNames(promptInvocation?.Tools);
if (allowedTools is null)
{
return;
}
sessionConfig.AvailableTools = BuildAvailableTools(sessionConfig.AvailableTools, allowedTools);
if (sessionConfig.Tools is null)
{
return;
}
List<AIFunction> filteredTools = sessionConfig.Tools
.Where(tool => IsAlwaysAllowedTool(tool.Name) || allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase))
.ToList();
sessionConfig.Tools = filteredTools.Count > 0 ? filteredTools : null;
}
internal static List<CustomAgentConfig>? CreateCustomAgents(
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
{
@@ -173,34 +208,117 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
};
}
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
internal static AIAgentHostOptions CreateAgentHostOptions()
{
return values is { Count: > 0 }
? [.. values]
: null;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
{
return pattern.Mode switch
return new AIAgentHostOptions
{
"single" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)),
"handoff" => BuildHandoffWorkflow(pattern),
"group-chat" => BuildGroupChatWorkflow(pattern),
"magentic" => throw new NotSupportedException(
pattern.UnavailabilityReason
?? "Magentic orchestration is not yet supported in the .NET Agent Framework."),
_ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."),
EmitAgentUpdateEvents = null,
EmitAgentResponseEvents = false,
InterceptUserInputRequests = false,
InterceptUnterminatedFunctionCalls = false,
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
}
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(
AIAgent entryAgent,
HandoffModeSettingsDto? settings = null)
{
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
if (effectiveSettings.ReturnToPrevious)
{
TryEnableReturnToPrevious(builder);
}
return builder;
}
internal static Workflow CreateHandoffWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
List<WorkflowNodeDto> specialistNodes = agentNodes
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
.ToList();
if (specialistNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
}
foreach (WorkflowNodeDto specialistNode in specialistNodes)
{
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
builder.WithHandoff(
triageAgent,
specialistAgent,
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
if (settings?.ReturnToPrevious != true)
{
builder.WithHandoff(
specialistAgent,
triageAgent,
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
}
}
return builder.Build();
}
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
participants => new RoundRobinGroupChatManager(participants)
{
MaximumIterationCount = maxRounds,
})
.AddParticipants(agents);
string? name = NormalizeOptionalString(workflowDefinition.Name);
if (name is not null)
{
builder.WithName(name);
}
string? description = NormalizeOptionalString(workflowDefinition.Description);
if (description is not null)
{
builder.WithDescription(description);
}
return builder;
}
internal static Workflow CreateGroupChatWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
}
public async ValueTask DisposeAsync()
{
foreach (IAsyncDisposable disposable in _disposables)
@@ -209,81 +327,167 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
}
}
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
{
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
Dictionary<string, PatternAgentDefinitionDto> definitionMap = pattern.Agents.ToDictionary(
definition => definition.Id,
definition => definition,
StringComparer.Ordinal);
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
string entryAgentId = agentMap.ContainsKey(topology.EntryAgentId)
? topology.EntryAgentId
: pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId;
AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0];
return values is { Count: > 0 }
? [.. values]
: null;
}
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
private static List<string> BuildAvailableTools(
ICollection<string>? existingAvailableTools,
IReadOnlyList<string> allowedTools)
{
List<string> availableTools = existingAvailableTools is { Count: > 0 }
? existingAvailableTools
.Where(tool => allowedTools.Contains(tool, StringComparer.OrdinalIgnoreCase))
.ToList()
: [.. allowedTools];
foreach (PatternHandoffRoute route in topology.Routes)
foreach (string requiredTool in RequiredPromptTools)
{
if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent)
|| !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent)
|| !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition))
if (!availableTools.Contains(requiredTool, StringComparer.OrdinalIgnoreCase))
{
continue;
availableTools.Add(requiredTool);
}
string handoffReason = string.Equals(
route.TargetAgentId,
topology.EntryAgentId,
StringComparison.Ordinal)
? HandoffWorkflowGuidance.CreateReturnReason(targetDefinition)
: HandoffWorkflowGuidance.CreateForwardReason(targetDefinition);
builder = builder.WithHandoff(
sourceAgent,
targetAgent,
handoffReason);
}
return builder.Build();
return availableTools;
}
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
private static bool IsAlwaysAllowedTool(string toolName)
{
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
return AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents =>
new RoundRobinGroupChatManager(agents)
{
MaximumIterationCount = maximumIterations,
})
.AddParticipants(ResolveOrderedAgents(pattern).ToArray())
.Build();
return toolName.StartsWith(HandoffToolPrefix, StringComparison.Ordinal)
|| RequiredPromptTools.Contains(toolName, StringComparer.OrdinalIgnoreCase);
}
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
private static IReadOnlyList<string>? NormalizeToolNames(IReadOnlyList<string>? values)
{
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
List<AIAgent> orderedAgents = PatternGraphResolver.ResolveOrderedAgentIds(pattern)
.Select(agentId => agentMap.TryGetValue(agentId, out AIAgent? agent) ? agent : null)
.Where(agent => agent is not null)
.Cast<AIAgent>()
.ToList();
return orderedAgents.Count == Agents.Count ? orderedAgents : Agents;
}
private Dictionary<string, AIAgent> BuildAgentMap(PatternDefinitionDto pattern)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.Ordinal);
foreach ((PatternAgentDefinitionDto definition, AIAgent agent) in pattern.Agents.Zip(Agents))
if (values is null)
{
agentMap[definition.Id] = agent;
return null;
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (!string.IsNullOrWhiteSpace(agent.Id))
{
agentMap[agent.Id] = agent;
}
if (!string.IsNullOrWhiteSpace(agent.Name))
{
agentMap[agent.Name] = agent;
}
}
return agentMap;
}
private static AIAgent ResolveAgentForNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentsById)
{
string agentId = node.GetAgentId();
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
{
return agent;
}
string agentName = node.GetAgentName();
if (agentsById.TryGetValue(agentName, out agent))
{
return agent;
}
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed Copilot agents.");
}
private static WorkflowNodeDto ResolveTriageAgentNode(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<WorkflowNodeDto> agentNodes)
{
if (agentNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
}
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
if (triageAgentNodeId is null)
{
return agentNodes[0];
}
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
return triageNode ?? throw new InvalidOperationException(
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
}
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"none" => HandoffToolCallFilteringBehavior.None,
"all" => HandoffToolCallFilteringBehavior.All,
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
};
}
private static void TryEnableReturnToPrevious(HandoffsWorkflowBuilder builder)
{
builder.GetType()
.GetMethod("EnableReturnToPrevious", Type.EmptyTypes)?
.Invoke(builder, null);
}
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
{
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
if (configuredMaxRounds is > 0)
{
return configuredMaxRounds.Value;
}
if (workflowDefinition.Settings.MaxIterations is > 0)
{
return workflowDefinition.Settings.MaxIterations.Value;
}
return 5;
}
private static string? ResolveEffectiveAgent(
string? defaultAgent,
RunTurnPromptInvocationDto? promptInvocation)
{
string? promptAgent = NormalizeOptionalString(promptInvocation?.Agent);
if (!string.IsNullOrWhiteSpace(promptAgent)
&& !string.Equals(promptAgent, "plan", StringComparison.OrdinalIgnoreCase))
{
return promptAgent;
}
IReadOnlyList<string>? promptTools = NormalizeToolNames(promptInvocation?.Tools);
if (promptTools is { Count: > 0 })
{
return "agent";
}
return NormalizeOptionalString(defaultAgent);
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -67,7 +67,7 @@ internal sealed class CopilotApprovalCoordinator
public async Task<PermissionRequestResult> RequestApprovalAsync(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
@@ -88,7 +88,7 @@ internal sealed class CopilotApprovalCoordinator
public async Task<PermissionRequestResult> RequestApprovalAsync(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
@@ -98,7 +98,7 @@ internal sealed class CopilotApprovalCoordinator
{
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
@@ -108,7 +108,7 @@ internal sealed class CopilotApprovalCoordinator
}
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|| !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey))
|| !RequiresToolCallApproval(command.Workflow.Settings.ApprovalPolicy, agent.GetAgentId(), toolName, autoApprovedToolName, mcpServerApprovalKey))
{
return CreateApprovalResult(PermissionRequestResultKind.Approved);
}
@@ -149,26 +149,16 @@ internal sealed class CopilotApprovalCoordinator
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
string approvalId,
string? toolName)
{
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
? "tool access"
: request.Kind.Trim();
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
if (request is PermissionRequestHook hook)
{
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
if (resolvedCategory is not null)
{
permissionKind = resolvedCategory;
}
}
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string agentId = agent.GetAgentId();
string agentName = agent.GetAgentName();
string? sessionId = NormalizeOptionalString(invocation.SessionId);
string? normalizedToolName = NormalizeOptionalString(toolName);
string? requestedUrl = request is PermissionRequestUrl urlRequest
@@ -202,19 +192,19 @@ internal sealed class CopilotApprovalCoordinator
SessionId = command.SessionId,
ApprovalId = approvalId,
ApprovalKind = ToolCallApprovalKind,
AgentId = NormalizeOptionalString(agent.Id),
AgentId = NormalizeOptionalString(agentId),
AgentName = NormalizeOptionalString(agentName),
ToolName = normalizedToolName,
PermissionKind = permissionKind,
Title = title,
Detail = detail,
PermissionDetail = BuildPermissionDetail(request),
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
};
}
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
PermissionRequest request,
string? toolName)
{
@@ -229,14 +219,15 @@ internal sealed class CopilotApprovalCoordinator
return null;
}
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string agentId = agent.GetAgentId();
string agentName = agent.GetAgentName();
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = ToolCallingActivityType,
AgentId = NormalizeOptionalString(agent.Id),
AgentId = NormalizeOptionalString(agentId),
AgentName = NormalizeOptionalString(agentName),
ToolName = NormalizeOptionalString(toolName),
ToolCallId = NormalizeOptionalString(write.ToolCallId),
@@ -252,7 +243,9 @@ internal sealed class CopilotApprovalCoordinator
};
}
internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request)
internal static PermissionDetailDto BuildPermissionDetail(
PermissionRequest request,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers = null)
{
ArgumentNullException.ThrowIfNull(request);
@@ -309,12 +302,7 @@ internal sealed class CopilotApprovalCoordinator
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
Args = customTool.Args,
},
PermissionRequestHook hook => new PermissionDetailDto
{
Kind = HookPermissionKind,
Args = hook.ToolArgs,
HookMessage = NormalizeOptionalString(hook.HookMessage),
},
PermissionRequestHook hook => BuildHookPermissionDetail(hook, configuredMcpServers),
_ => new PermissionDetailDto
{
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
@@ -430,15 +418,45 @@ internal sealed class CopilotApprovalCoordinator
private const string McpServerApprovalPrefix = "mcp_server:";
private static string? ResolveMcpServerApprovalKey(PermissionRequest request)
private static string? ResolveMcpServerApprovalKey(
PermissionRequest request,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
if (request is not PermissionRequestMcp mcp)
return request switch
{
PermissionRequestMcp mcp => BuildMcpServerApprovalKey(mcp.ServerName),
PermissionRequestHook hook => ResolveHookMcpServerApprovalKey(hook.ToolName, configuredMcpServers),
_ => null,
};
}
internal static string? BuildMcpServerApprovalKey(string? serverName)
{
string? normalizedServerName = NormalizeOptionalString(serverName);
return normalizedServerName is not null ? $"{McpServerApprovalPrefix}{normalizedServerName}" : null;
}
internal static string? ResolveHookMcpServerApprovalKey(
string? toolName,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
=> BuildMcpServerApprovalKey(ResolveHookMcpServerName(toolName, configuredMcpServers));
internal static string? ResolveHookMcpServerName(
string? toolName,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string? normalizedToolName = NormalizeOptionalString(toolName);
if (normalizedToolName is null || configuredMcpServers is null || configuredMcpServers.Count == 0)
{
return null;
}
string? serverName = NormalizeOptionalString(mcp.ServerName);
return serverName is not null ? $"{McpServerApprovalPrefix}{serverName}" : null;
return configuredMcpServers
.Select(ResolveConfiguredMcpServerName)
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(static serverName => serverName.Length)
.FirstOrDefault(serverName => MatchesHookMcpServerToolName(normalizedToolName, serverName));
}
private static string? ResolveApprovalCacheKey(
@@ -520,6 +538,87 @@ internal sealed class CopilotApprovalCoordinator
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
}
private static string ResolvePermissionKind(
PermissionRequest request,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
? "tool access"
: request.Kind.Trim();
if (request is not PermissionRequestHook hook)
{
return permissionKind;
}
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
if (resolvedCategory is not null)
{
return resolvedCategory;
}
return ResolveHookMcpServerName(hook.ToolName, configuredMcpServers) is not null
? McpPermissionKind
: permissionKind;
}
private static PermissionDetailDto BuildHookPermissionDetail(
PermissionRequestHook hook,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string? serverName = ResolveHookMcpServerName(hook.ToolName, configuredMcpServers);
if (serverName is null)
{
return new PermissionDetailDto
{
Kind = HookPermissionKind,
Args = hook.ToolArgs,
HookMessage = NormalizeOptionalString(hook.HookMessage),
};
}
return new PermissionDetailDto
{
Kind = McpPermissionKind,
ServerName = serverName,
ToolTitle = ResolveHookMcpToolTitle(hook.ToolName, serverName),
Args = hook.ToolArgs,
};
}
private static string? ResolveConfiguredMcpServerName(RunTurnMcpServerConfigDto configuredServer)
=> NormalizeOptionalString(configuredServer.Name) ?? NormalizeOptionalString(configuredServer.Id);
private static bool MatchesHookMcpServerToolName(string toolName, string serverName)
{
if (string.Equals(toolName, serverName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return toolName.StartsWith($"{serverName}-", StringComparison.OrdinalIgnoreCase);
}
private static string? ResolveHookMcpToolTitle(string? toolName, string serverName)
{
string? normalizedToolName = NormalizeOptionalString(toolName);
if (normalizedToolName is null)
{
return null;
}
string prefix = $"{serverName}-";
if (!normalizedToolName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return normalizedToolName;
}
string strippedToolName = normalizedToolName[prefix.Length..];
return string.IsNullOrWhiteSpace(strippedToolName)
? normalizedToolName
: strippedToolName;
}
private static bool MatchesAutoApprovedTool(
IReadOnlyList<string> autoApprovedToolNames,
string? toolName,
@@ -11,7 +11,7 @@ internal sealed class CopilotExitPlanModeCoordinator
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
ExitPlanModeRequestedEvent request)
{
ArgumentNullException.ThrowIfNull(command);
@@ -33,7 +33,7 @@ internal sealed class CopilotExitPlanModeCoordinator
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
ExitPlanModeRequestedEvent request)
{
ArgumentNullException.ThrowIfNull(command);
@@ -45,8 +45,8 @@ internal sealed class CopilotExitPlanModeCoordinator
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
return new ExitPlanModeRequestedEventDto
{
@@ -7,7 +7,7 @@ internal sealed class CopilotMcpOAuthCoordinator
{
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
McpOauthRequiredEvent request)
{
ArgumentNullException.ThrowIfNull(command);
@@ -19,8 +19,8 @@ internal sealed class CopilotMcpOAuthCoordinator
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
return new McpOauthRequiredEventDto
{
@@ -40,7 +40,7 @@ internal static class CopilotSessionHooks
public static SessionHooks Create(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
WorkflowNodeDto agentDefinition,
ResolvedHookSet? configuredHooks = null,
IHookCommandRunner? hookCommandRunner = null)
{
@@ -62,7 +62,7 @@ internal static class CopilotSessionHooks
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
WorkflowNodeDto agentDefinition,
ResolvedHookSet configuredHooks,
IHookCommandRunner hookCommandRunner,
PreToolUseHookInput input)
@@ -236,7 +236,7 @@ internal static class CopilotSessionHooks
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
WorkflowNodeDto agentDefinition,
PreToolUseHookInput input)
{
string? toolName = Normalize(input.ToolName);
@@ -249,12 +249,16 @@ internal static class CopilotSessionHooks
}
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
toolName,
command.Tooling?.McpServers);
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Pattern.ApprovalPolicy,
agentDefinition.Id,
command.Workflow.Settings.ApprovalPolicy,
agentDefinition.GetAgentId(),
toolName,
autoApprovedToolName);
autoApprovedToolName,
mcpServerApprovalKey);
return new PreToolUseHookOutput
{
@@ -54,6 +54,11 @@ internal sealed class CopilotTurnExecutionState
}
}
public void QueueCompletedActivity(AgentIdentity agent)
{
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
}
public void ApplyEvent(SidecarEventDto evt)
{
if (evt is AgentActivityEventDto activity
@@ -65,12 +70,12 @@ internal sealed class CopilotTurnExecutionState
}
}
public void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, SessionEvent sessionEvent)
{
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
_command.Pattern,
agentDefinition.Id,
agentDefinition.Name);
_command.Workflow,
agentDefinition.GetAgentId(),
agentDefinition.GetAgentName());
switch (sessionEvent)
{
@@ -89,7 +94,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 +292,42 @@ 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 AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
{
return new MessageReclassifiedEventDto
@@ -32,7 +32,7 @@ internal sealed class CopilotUserInputCoordinator
public async Task<UserInputResponse> RequestUserInputAsync(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
UserInputRequest request,
UserInputInvocation invocation,
Func<UserInputRequestedEventDto, Task> onUserInput,
@@ -44,6 +44,93 @@ internal sealed class CopilotUserInputCoordinator
ArgumentNullException.ThrowIfNull(invocation);
ArgumentNullException.ThrowIfNull(onUserInput);
return await RequestUserInputCoreAsync(
command,
agent.GetAgentId(),
agent.GetAgentName(),
request,
onUserInput,
cancellationToken).ConfigureAwait(false);
}
public Task<UserInputResponse> RequestUserInputAsync(
RunTurnCommandDto command,
UserInputRequest request,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(onUserInput);
return RequestUserInputCoreAsync(
command,
agentId: null,
agentName: null,
request,
onUserInput,
cancellationToken);
}
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
RunTurnCommandDto command,
WorkflowNodeDto agent,
UserInputRequest request,
string userInputId)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(request);
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
return new UserInputRequestedEventDto
{
Type = "user-input-requested",
RequestId = command.RequestId,
SessionId = command.SessionId,
UserInputId = userInputId,
AgentId = normalizedAgentId,
AgentName = normalizedAgentName,
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
Choices = NormalizeOptionalStringList(request.Choices ?? []),
AllowFreeform = request.AllowFreeform,
};
}
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
RunTurnCommandDto command,
string? agentId,
string? agentName,
UserInputRequest request,
string userInputId)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(request);
return new UserInputRequestedEventDto
{
Type = "user-input-requested",
RequestId = command.RequestId,
SessionId = command.SessionId,
UserInputId = userInputId,
AgentId = NormalizeOptionalString(agentId),
AgentName = NormalizeOptionalString(agentName),
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
Choices = NormalizeOptionalStringList(request.Choices ?? []),
AllowFreeform = request.AllowFreeform,
};
}
private async Task<UserInputResponse> RequestUserInputCoreAsync(
RunTurnCommandDto command,
string? agentId,
string? agentName,
UserInputRequest request,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken)
{
PendingUserInputRequest pending = CreatePendingUserInput(command);
if (!_pendingUserInputs.TryAdd(pending.UserInputId, pending))
{
@@ -52,7 +139,7 @@ internal sealed class CopilotUserInputCoordinator
try
{
await onUserInput(BuildUserInputRequestedEvent(command, agent, request, pending.UserInputId))
await onUserInput(BuildUserInputRequestedEvent(command, agentId, agentName, request, pending.UserInputId))
.ConfigureAwait(false);
using CancellationTokenRegistration registration = cancellationToken.Register(
@@ -71,33 +158,6 @@ internal sealed class CopilotUserInputCoordinator
}
}
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
UserInputRequest request,
string userInputId)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(request);
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
return new UserInputRequestedEventDto
{
Type = "user-input-requested",
RequestId = command.RequestId,
SessionId = command.SessionId,
UserInputId = userInputId,
AgentId = normalizedAgentId,
AgentName = normalizedAgentName,
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
Choices = NormalizeOptionalStringList(request.Choices ?? []),
AllowFreeform = request.AllowFreeform,
};
}
private static PendingUserInputRequest CreatePendingUserInput(RunTurnCommandDto command)
{
return new PendingUserInputRequest(
@@ -1,7 +1,13 @@
using System.IO;
using System.Linq;
using System.Globalization;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
@@ -9,15 +15,16 @@ namespace Aryx.AgentHost.Services;
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
private const string HandoffFunctionPrefix = "handoff_to_";
private readonly PatternValidator _patternValidator;
private readonly WorkflowValidator _workflowValidator;
private readonly WorkflowRunner _workflowRunner = new();
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
public CopilotWorkflowRunner(PatternValidator patternValidator)
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
{
_patternValidator = patternValidator;
_workflowValidator = workflowValidator ?? new WorkflowValidator();
}
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
@@ -30,10 +37,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
string? validationError = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary)
.FirstOrDefault()?.Message;
if (validationError is not null)
{
throw new InvalidOperationException(validationError.Message);
throw new InvalidOperationException(validationError);
}
CopilotTurnExecutionState state = new(command);
@@ -77,15 +85,35 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
},
runCancellation.Token);
ConfigureHookLifecycleEventSuppression(state, bundle);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
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))
{
if (evt is RequestInfoEvent requestInfo
&& await TryHandleRequestPortRequestAsync(
command,
requestInfo,
run,
onUserInput,
runCancellation.Token).ConfigureAwait(false))
{
continue;
}
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
.ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
@@ -120,6 +148,91 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
internal static Workflow BuildWorkflowForCommand(
RunTurnCommandDto command,
IReadOnlyList<AIAgent> agents,
WorkflowRunner? workflowRunner = null)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agents);
return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch
{
"handoff" => CopilotAgentBundle.CreateHandoffWorkflow(command.Workflow, agents),
"group-chat" => CopilotAgentBundle.CreateGroupChatWorkflow(command.Workflow, agents),
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
};
}
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 command.Workflow.Settings.Checkpointing.Enabled;
}
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 string? NormalizeOrchestrationMode(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}
private static ValueTask<StreamingRun> OpenWorkflowRunAsync(
RunTurnCommandDto command,
Workflow workflow,
IReadOnlyList<ChatMessage> inputMessages,
CheckpointManager? checkpointManager)
{
InProcessExecutionEnvironment environment = CreateExecutionEnvironment(command, checkpointManager);
if (checkpointManager is not null && command.ResumeFromCheckpoint is { } resumeFromCheckpoint)
{
return environment.ResumeStreamingAsync(
workflow,
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId));
}
return environment.RunStreamingAsync(workflow, inputMessages.ToList(), command.RequestId);
}
internal static InProcessExecutionEnvironment CreateExecutionEnvironment(
RunTurnCommandDto command,
CheckpointManager? checkpointManager)
{
ArgumentNullException.ThrowIfNull(command);
string executionMode = command.Workflow.Settings.ExecutionMode?.Trim() ?? "off-thread";
InProcessExecutionEnvironment environment = string.Equals(
executionMode,
"lockstep",
StringComparison.OrdinalIgnoreCase)
? InProcessExecution.Lockstep
: InProcessExecution.OffThread;
return checkpointManager is null ? environment : environment.WithCheckpointing(checkpointManager);
}
internal static void ConfigureHookLifecycleEventSuppression(
CopilotTurnExecutionState state,
CopilotAgentBundle bundle)
@@ -164,6 +277,30 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
}
private async Task<bool> TryHandleRequestPortRequestAsync(
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
StreamingRun run,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken)
{
if (!TryResolveRequestPortMetadata(command.Workflow, requestInfo, out WorkflowRequestPortMetadata? metadata))
{
return false;
}
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
UserInputResponse response = await _userInputCoordinator.RequestUserInputAsync(
command,
userInputRequest,
onUserInput,
cancellationToken).ConfigureAwait(false);
object coercedResponse = CoerceRequestPortResponse(metadata!.ResponseType, response.Answer);
await run.SendResponseAsync(requestInfo.Request.CreateResponse(coercedResponse)).ConfigureAwait(false);
return true;
}
private static async Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
@@ -175,7 +312,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (evt is ExecutorInvokedEvent invoked)
{
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
command.Pattern,
command.Workflow,
invoked.ExecutorId,
out AgentIdentity invokedAgent))
{
@@ -211,6 +348,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);
@@ -220,12 +369,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (evt is ExecutorCompletedEvent completed)
{
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Pattern,
command.Workflow,
completed.ExecutorId,
state.ActiveAgent,
out AgentIdentity completedAgent))
{
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
state.QueueCompletedActivity(completedAgent);
state.ClearActiveAgentIfMatching(completedAgent);
}
else
@@ -245,6 +395,74 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return false;
}
internal static UserInputRequest CreateRequestPortUserInputRequest(
WorkflowRequestPortMetadata metadata,
RequestInfoEvent requestInfo)
{
ArgumentNullException.ThrowIfNull(metadata);
ArgumentNullException.ThrowIfNull(requestInfo);
string question = metadata.Prompt
?? BuildRequestPortFallbackQuestion(metadata, requestInfo);
bool expectsBoolean = IsBooleanResponseType(metadata.ResponseType);
return new UserInputRequest
{
Question = question,
Choices = expectsBoolean ? ["true", "false"] : null,
AllowFreeform = true,
};
}
internal static object CoerceRequestPortResponse(string responseType, string? answer)
{
string normalizedResponseType = responseType.Trim();
string trimmedAnswer = answer?.Trim() ?? string.Empty;
if (IsStringResponseType(normalizedResponseType))
{
return trimmedAnswer;
}
if (IsBooleanResponseType(normalizedResponseType))
{
return trimmedAnswer.ToLowerInvariant() switch
{
"true" or "t" or "yes" or "y" or "1" => true,
"false" or "f" or "no" or "n" or "0" => false,
_ => throw new InvalidOperationException(
$"Request port response type \"{responseType}\" requires a boolean answer."),
};
}
if (IsNumericResponseType(normalizedResponseType))
{
if (double.TryParse(trimmedAnswer, NumberStyles.Float, CultureInfo.InvariantCulture, out double numeric))
{
return numeric;
}
throw new InvalidOperationException(
$"Request port response type \"{responseType}\" requires a numeric answer.");
}
if (IsJsonResponseType(normalizedResponseType))
{
try
{
return JsonDocument.Parse(trimmedAnswer).RootElement.Clone();
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Request port response type \"{responseType}\" requires a valid JSON answer.",
ex);
}
}
return trimmedAnswer;
}
private static async Task HandleAgentResponseUpdateAsync(
RunTurnCommandDto command,
AgentResponseUpdateEvent update,
@@ -266,7 +484,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
authorName = observedMessageAgent.AgentName;
}
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Pattern,
command.Workflow,
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
@@ -274,6 +492,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 +567,198 @@ 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.Workflow,
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 bool TryResolveRequestPortMetadata(
WorkflowDefinitionDto? workflow,
RequestInfoEvent requestInfo,
out WorkflowRequestPortMetadata? metadata)
{
metadata = null;
if (workflow is null)
{
return false;
}
string portId = requestInfo.Request.PortInfo.PortId;
WorkflowNodeDto? node = workflow.Graph.Nodes.FirstOrDefault(candidate =>
string.Equals(candidate.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
&& string.Equals(candidate.Config.PortId, portId, StringComparison.OrdinalIgnoreCase));
if (node is null)
{
return false;
}
metadata = new WorkflowRequestPortMetadata(
node.Id,
string.IsNullOrWhiteSpace(node.Label) ? node.Id : node.Label,
node.Config.PortId ?? portId,
node.Config.RequestType ?? string.Empty,
node.Config.ResponseType ?? string.Empty,
string.IsNullOrWhiteSpace(node.Config.Prompt) ? null : node.Config.Prompt.Trim());
return true;
}
private static string BuildRequestPortFallbackQuestion(
WorkflowRequestPortMetadata metadata,
RequestInfoEvent requestInfo)
{
if (requestInfo.Request.Data.Is<WorkflowRequestPortPromptRequest>(out WorkflowRequestPortPromptRequest? promptRequest))
{
string baseQuestion = $"Provide a {metadata.ResponseType} response for \"{promptRequest.NodeLabel}\".";
if (!string.IsNullOrWhiteSpace(promptRequest.InputSummary))
{
return $"{baseQuestion} Current input: {promptRequest.InputSummary}";
}
return baseQuestion;
}
return $"Provide a {metadata.ResponseType} response for request port \"{metadata.NodeLabel}\" ({metadata.PortId}).";
}
private static bool IsStringResponseType(string responseType)
=> string.Equals(responseType, "string", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "text", StringComparison.OrdinalIgnoreCase);
private static bool IsBooleanResponseType(string responseType)
=> string.Equals(responseType, "bool", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "boolean", StringComparison.OrdinalIgnoreCase);
private static bool IsNumericResponseType(string responseType)
=> string.Equals(responseType, "number", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "int", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "float", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "double", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "decimal", StringComparison.OrdinalIgnoreCase);
private static bool IsJsonResponseType(string responseType)
=> string.Equals(responseType, "json", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "object", StringComparison.OrdinalIgnoreCase)
|| string.Equals(responseType, "array", StringComparison.OrdinalIgnoreCase);
internal sealed record WorkflowRequestPortMetadata(
string NodeId,
string NodeLabel,
string PortId,
string RequestType,
string ResponseType,
string? Prompt);
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)
@@ -349,7 +767,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static void TraceHandoff(RunTurnCommandDto command, string message)
{
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
if (!command.Workflow.IsOrchestrationMode("handoff"))
{
return;
}
@@ -8,26 +8,30 @@ 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.
""";
}
public static string CreateForwardReason(PatternAgentDefinitionDto target)
public static string CreateForwardReason(WorkflowNodeDto target)
{
string specialty = string.IsNullOrWhiteSpace(target.Description)
? target.Name
: target.Description.TrimEnd('.');
string specialty = string.IsNullOrWhiteSpace(target.Config.Description)
? target.GetAgentName()
: target.Config.Description.TrimEnd('.');
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.Name} own the substantive response.";
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.GetAgentName()} own the substantive response.";
}
public static string CreateReturnReason(PatternAgentDefinitionDto triageAgent)
public static string CreateReturnReason(WorkflowNodeDto triageAgent)
{
return $"Hand off back to {triageAgent.Name} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
return $"Hand off back to {triageAgent.GetAgentName()} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
}
}
@@ -1,345 +0,0 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal sealed record PatternHandoffRoute(string SourceAgentId, string TargetAgentId);
internal sealed record PatternHandoffTopology(string EntryAgentId, IReadOnlyList<PatternHandoffRoute> Routes);
internal static class PatternGraphResolver
{
private const string UserInputKind = "user-input";
private const string UserOutputKind = "user-output";
private const string AgentKind = "agent";
private const string DistributorKind = "distributor";
private const string CollectorKind = "collector";
private const string OrchestratorKind = "orchestrator";
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
public static PatternGraphDto Resolve(PatternDefinitionDto pattern)
=> pattern.Graph ?? CreateDefault(pattern);
public static IReadOnlyList<string> ResolveOrderedAgentIds(PatternDefinitionDto pattern)
{
PatternGraphDto graph = Resolve(pattern);
return pattern.Mode switch
{
"single" or "sequential" or "magentic" => ResolveLinearAgentIds(pattern, graph),
"concurrent" or "group-chat" or "handoff" => ResolveAgentOrder(pattern, graph),
_ => pattern.Agents.Select(agent => agent.Id).ToList()
};
}
public static PatternHandoffTopology ResolveHandoff(PatternDefinitionDto pattern)
{
return TryResolveHandoff(pattern, Resolve(pattern))
?? TryResolveHandoff(pattern, CreateDefault(pattern))
?? new PatternHandoffTopology(
pattern.Agents.FirstOrDefault()?.Id ?? string.Empty,
[]);
}
public static PatternGraphDto CreateDefault(PatternDefinitionDto pattern)
{
return pattern.Mode switch
{
"single" or "sequential" or "magentic" => CreateLinearGraph(pattern.Agents),
"concurrent" => CreateConcurrentGraph(pattern.Agents),
"handoff" => CreateHandoffGraph(pattern.Agents),
"group-chat" => CreateGroupChatGraph(pattern.Agents),
_ => CreateLinearGraph(pattern.Agents)
};
}
private static IReadOnlyList<string> ResolveLinearAgentIds(PatternDefinitionDto pattern, PatternGraphDto graph)
{
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, UserOutputKind);
if (inputNode is null || outputNode is null)
{
return pattern.Agents.Select(agent => agent.Id).ToList();
}
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
List<string> orderedAgentIds = [];
HashSet<string> visitedNodeIds = [];
string currentNodeId = inputNode.Id;
while (visitedNodeIds.Add(currentNodeId))
{
if (!outgoing.TryGetValue(currentNodeId, out List<PatternGraphEdgeDto>? edges) || edges.Count != 1)
{
break;
}
string nextNodeId = edges[0].Target;
if (!nodesById.TryGetValue(nextNodeId, out PatternGraphNodeDto? nextNode))
{
break;
}
if (Comparer.Equals(nextNode.Id, outputNode.Id))
{
break;
}
if (Comparer.Equals(nextNode.Kind, AgentKind) && !string.IsNullOrWhiteSpace(nextNode.AgentId))
{
orderedAgentIds.Add(nextNode.AgentId);
}
currentNodeId = nextNodeId;
}
return orderedAgentIds.Count == pattern.Agents.Count
? orderedAgentIds
: pattern.Agents.Select(agent => agent.Id).ToList();
}
private static IReadOnlyList<string> ResolveAgentOrder(PatternDefinitionDto pattern, PatternGraphDto graph)
{
Dictionary<string, int> fallbackOrder = pattern.Agents
.Select((agent, index) => new { agent.Id, Index = index })
.ToDictionary(item => item.Id, item => item.Index);
List<string> orderedAgentIds = graph.Nodes
.Where(node => Comparer.Equals(node.Kind, AgentKind) && !string.IsNullOrWhiteSpace(node.AgentId))
.OrderBy(node => node.Order ?? int.MaxValue)
.ThenBy(node => fallbackOrder.GetValueOrDefault(node.AgentId!, int.MaxValue))
.Select(node => node.AgentId!)
.Distinct()
.ToList();
return orderedAgentIds.Count == pattern.Agents.Count
? orderedAgentIds
: pattern.Agents.Select(agent => agent.Id).ToList();
}
private static PatternHandoffTopology? TryResolveHandoff(PatternDefinitionDto pattern, PatternGraphDto graph)
{
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
string? entryAgentId = null;
if (inputNode is not null)
{
entryAgentId = graph.Edges
.Where(edge => Comparer.Equals(edge.Source, inputNode.Id))
.Select(edge => nodesById.TryGetValue(edge.Target, out PatternGraphNodeDto? targetNode)
? targetNode.AgentId
: null)
.FirstOrDefault(agentId => !string.IsNullOrWhiteSpace(agentId));
}
List<PatternHandoffRoute> routes = graph.Edges
.Select(edge => (SourceNode: nodesById.GetValueOrDefault(edge.Source), TargetNode: nodesById.GetValueOrDefault(edge.Target)))
.Where(item =>
item.SourceNode is not null
&& item.TargetNode is not null
&& Comparer.Equals(item.SourceNode.Kind, AgentKind)
&& Comparer.Equals(item.TargetNode.Kind, AgentKind)
&& !string.IsNullOrWhiteSpace(item.SourceNode.AgentId)
&& !string.IsNullOrWhiteSpace(item.TargetNode.AgentId))
.Select(item => new PatternHandoffRoute(item.SourceNode!.AgentId!, item.TargetNode!.AgentId!))
.Distinct()
.ToList();
if (string.IsNullOrWhiteSpace(entryAgentId) || routes.Count == 0)
{
return null;
}
return new PatternHandoffTopology(entryAgentId!, routes);
}
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
{
Dictionary<string, List<PatternGraphEdgeDto>> lookup = new(StringComparer.Ordinal);
foreach (PatternGraphNodeDto node in graph.Nodes)
{
lookup[node.Id] = [];
}
foreach (PatternGraphEdgeDto edge in graph.Edges)
{
if (!lookup.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
{
edges = [];
lookup[edge.Source] = edges;
}
edges.Add(edge);
}
return lookup;
}
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
private static PatternGraphDto CreateLinearGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
{
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 220 * Math.Max(agents.Count + 1, 2), 0);
List<PatternGraphNodeDto> agentNodes = agents
.Select((agent, index) => CreateAgentNode(agent, index, 220 * (index + 1), 0))
.ToList();
List<PatternGraphEdgeDto> edges = [];
List<string> path = [inputNode.Id, .. agentNodes.Select(node => node.Id), outputNode.Id];
for (int index = 0; index < path.Count - 1; index += 1)
{
edges.Add(CreateEdge(path[index], path[index + 1]));
}
return new PatternGraphDto
{
Nodes = [inputNode, .. agentNodes, outputNode],
Edges = edges
};
}
private static PatternGraphDto CreateConcurrentGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
{
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
PatternGraphNodeDto distributorNode = CreateSystemNode("system-distributor", DistributorKind, 190, 0);
PatternGraphNodeDto collectorNode = CreateSystemNode("system-collector", CollectorKind, 650, 0);
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
List<PatternGraphNodeDto> agentNodes = agents
.Select((agent, index) => CreateAgentNode(agent, index, 430, SpreadY(index, Math.Max(agents.Count, 1), 170)))
.ToList();
return new PatternGraphDto
{
Nodes = [inputNode, distributorNode, .. agentNodes, collectorNode, outputNode],
Edges =
[
CreateEdge(inputNode.Id, distributorNode.Id),
.. agentNodes.Select(node => CreateEdge(distributorNode.Id, node.Id)),
.. agentNodes.Select(node => CreateEdge(node.Id, collectorNode.Id)),
CreateEdge(collectorNode.Id, outputNode.Id)
]
};
}
private static PatternGraphDto CreateHandoffGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
{
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
PatternAgentDefinitionDto? entryAgent = agents.FirstOrDefault();
PatternGraphNodeDto? entryNode = entryAgent is null
? null
: CreateAgentNode(entryAgent, 0, 220, 0);
List<PatternGraphNodeDto> specialistNodes = agents
.Skip(1)
.Select((agent, index) => CreateAgentNode(agent, index + 1, 540, SpreadY(index, Math.Max(agents.Count - 1, 1), 220)))
.ToList();
List<PatternGraphEdgeDto> edges = [];
if (entryNode is not null)
{
edges.Add(CreateEdge(inputNode.Id, entryNode.Id));
edges.Add(CreateEdge(entryNode.Id, outputNode.Id));
foreach (PatternGraphNodeDto specialistNode in specialistNodes)
{
edges.Add(CreateEdge(entryNode.Id, specialistNode.Id));
edges.Add(CreateEdge(specialistNode.Id, entryNode.Id));
edges.Add(CreateEdge(specialistNode.Id, outputNode.Id));
}
}
List<PatternGraphNodeDto> nodes = [inputNode];
if (entryNode is not null)
{
nodes.Add(entryNode);
}
nodes.AddRange(specialistNodes);
nodes.Add(outputNode);
return new PatternGraphDto
{
Nodes = nodes,
Edges = edges
};
}
private static PatternGraphDto CreateGroupChatGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
{
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
PatternGraphNodeDto orchestratorNode = CreateSystemNode("system-orchestrator", OrchestratorKind, 250, 0);
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 900, 0);
const double centerX = 560;
const double centerY = 0;
const double radiusX = 190;
const double radiusY = 170;
List<PatternGraphNodeDto> agentNodes = agents
.Select((agent, index) =>
{
double angle = agents.Count <= 1
? 0
: (Math.PI * 2 * index) / agents.Count - (Math.PI / 2);
return CreateAgentNode(
agent,
index,
Math.Round(centerX + Math.Cos(angle) * radiusX),
Math.Round(centerY + Math.Sin(angle) * radiusY));
})
.ToList();
return new PatternGraphDto
{
Nodes = [inputNode, orchestratorNode, .. agentNodes, outputNode],
Edges =
[
CreateEdge(inputNode.Id, orchestratorNode.Id),
.. agentNodes.SelectMany(node => new[]
{
CreateEdge(orchestratorNode.Id, node.Id),
CreateEdge(node.Id, orchestratorNode.Id)
}),
CreateEdge(orchestratorNode.Id, outputNode.Id)
]
};
}
private static PatternGraphNodeDto CreateSystemNode(string id, string kind, double x, double y)
=> new()
{
Id = id,
Kind = kind,
Position = new PatternGraphPositionDto
{
X = x,
Y = y
}
};
private static PatternGraphNodeDto CreateAgentNode(PatternAgentDefinitionDto agent, int order, double x, double y)
=> new()
{
Id = $"agent-node-{agent.Id}",
Kind = AgentKind,
AgentId = agent.Id,
Order = order,
Position = new PatternGraphPositionDto
{
X = x,
Y = y
}
};
private static PatternGraphEdgeDto CreateEdge(string source, string target)
=> new()
{
Id = $"edge-{source}-to-{target}",
Source = source,
Target = target
};
private static double SpreadY(int index, int count, double gap)
=> (index - ((count - 1) / 2d)) * gap;
}
@@ -1,573 +0,0 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public sealed class PatternValidator
{
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
{
List<PatternValidationIssueDto> issues = [];
if (string.IsNullOrWhiteSpace(pattern.Name))
{
issues.Add(new PatternValidationIssueDto
{
Field = "name",
Message = "Pattern name is required.",
});
}
if (string.Equals(pattern.Availability, "unavailable", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new PatternValidationIssueDto
{
Field = "availability",
Message = pattern.UnavailabilityReason ?? "This orchestration mode is currently unavailable.",
});
}
if (pattern.Agents.Count == 0)
{
issues.Add(new PatternValidationIssueDto
{
Field = "agents",
Message = "At least one agent is required.",
});
}
if (string.Equals(pattern.Mode, "single", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count != 1)
{
issues.Add(new PatternValidationIssueDto
{
Field = "agents",
Message = "Single-agent chat requires exactly one agent.",
});
}
if (string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
{
issues.Add(new PatternValidationIssueDto
{
Field = "agents",
Message = "Handoff orchestration requires at least two agents.",
});
}
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
{
issues.Add(new PatternValidationIssueDto
{
Field = "agents",
Message = "Group chat requires at least two agents.",
});
}
if (string.Equals(pattern.Mode, "magentic", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new PatternValidationIssueDto
{
Field = "mode",
Message = pattern.UnavailabilityReason
?? "Magentic orchestration is currently documented as unsupported in the .NET Agent Framework.",
});
}
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
{
if (string.IsNullOrWhiteSpace(agent.Name))
{
issues.Add(new PatternValidationIssueDto
{
Field = "agents.name",
Message = "Every agent needs a name.",
});
}
if (string.IsNullOrWhiteSpace(agent.Model))
{
issues.Add(new PatternValidationIssueDto
{
Field = "agents.model",
Message = $"Agent \"{agent.Name}\" requires a model identifier.",
});
}
}
ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), issues);
return issues;
}
private static void ValidateGraph(
PatternDefinitionDto pattern,
PatternGraphDto graph,
List<PatternValidationIssueDto> issues)
{
if (graph.Nodes.Count == 0)
{
AddGraphIssue(issues, "Pattern graph must include nodes.");
return;
}
HashSet<string> nodeIds = new(StringComparer.Ordinal);
HashSet<string> edgeIds = new(StringComparer.Ordinal);
HashSet<string> agentIds = pattern.Agents.Select(agent => agent.Id).ToHashSet(StringComparer.Ordinal);
HashSet<string> seenAgentIds = new(StringComparer.Ordinal);
HashSet<int> seenAgentOrders = [];
Dictionary<string, PatternGraphNodeDto> nodesById = new(StringComparer.Ordinal);
foreach (PatternGraphNodeDto node in graph.Nodes)
{
if (!nodeIds.Add(node.Id))
{
AddGraphIssue(issues, $"Pattern graph contains duplicate node \"{node.Id}\".");
}
nodesById[node.Id] = node;
if (Comparer.Equals(node.Kind, "agent"))
{
if (string.IsNullOrWhiteSpace(node.AgentId) || !agentIds.Contains(node.AgentId))
{
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must reference a known agent.");
}
if (!string.IsNullOrWhiteSpace(node.AgentId) && !seenAgentIds.Add(node.AgentId))
{
AddGraphIssue(issues, $"Pattern graph contains multiple nodes for agent \"{node.AgentId}\".");
}
if (!node.Order.HasValue)
{
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must define an order.");
}
else if (!seenAgentOrders.Add(node.Order.Value))
{
AddGraphIssue(issues, $"Pattern graph contains duplicate agent order \"{node.Order.Value}\".");
}
}
else if (!string.IsNullOrWhiteSpace(node.AgentId))
{
AddGraphIssue(issues, $"System node \"{node.Id}\" cannot reference an agent.");
}
}
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
{
if (!seenAgentIds.Contains(agent.Id))
{
AddGraphIssue(issues, $"Pattern graph is missing node metadata for agent \"{agent.Id}\".");
}
}
foreach (PatternGraphEdgeDto edge in graph.Edges)
{
if (!edgeIds.Add(edge.Id))
{
AddGraphIssue(issues, $"Pattern graph contains duplicate edge \"{edge.Id}\".");
}
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
{
AddGraphIssue(issues, $"Pattern graph edge \"{edge.Id}\" must connect known nodes.");
}
}
switch (pattern.Mode)
{
case "single":
case "sequential":
case "magentic":
ValidateLinearGraph(pattern, graph, issues);
break;
case "concurrent":
ValidateConcurrentGraph(pattern, graph, issues);
break;
case "handoff":
ValidateHandoffGraph(graph, issues);
break;
case "group-chat":
ValidateGroupChatGraph(pattern, graph, issues);
break;
}
}
private static void ValidateLinearGraph(
PatternDefinitionDto pattern,
PatternGraphDto graph,
List<PatternValidationIssueDto> issues)
{
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
if (inputNode is null || outputNode is null)
{
return;
}
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
if (graph.Edges.Count != pattern.Agents.Count + 1)
{
AddGraphIssue(issues, "Linear orchestration graphs must be a single path from user input through every agent to user output.");
}
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
{
AddGraphIssue(issues, "User input must start exactly one path.");
}
if (incoming.GetValueOrDefault(outputNode.Id, []).Count != 1 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
{
AddGraphIssue(issues, "User output must terminate exactly one path.");
}
foreach (PatternGraphNodeDto node in agentNodes)
{
if (incoming.GetValueOrDefault(node.Id, []).Count != 1 || outgoing.GetValueOrDefault(node.Id, []).Count != 1)
{
AddGraphIssue(issues, "Each agent in a linear orchestration must have exactly one incoming and one outgoing edge.");
break;
}
}
HashSet<string> visited = new(StringComparer.Ordinal);
string currentNodeId = inputNode.Id;
while (visited.Add(currentNodeId))
{
List<PatternGraphEdgeDto> nextEdges = outgoing.GetValueOrDefault(currentNodeId, []);
if (nextEdges.Count == 0)
{
break;
}
if (nextEdges.Count != 1)
{
AddGraphIssue(issues, "Linear orchestration nodes may only branch to one next step.");
break;
}
currentNodeId = nextEdges[0].Target;
if (Comparer.Equals(currentNodeId, outputNode.Id))
{
visited.Add(currentNodeId);
break;
}
}
HashSet<string> expectedVisited = new(StringComparer.Ordinal)
{
inputNode.Id,
outputNode.Id
};
foreach (PatternGraphNodeDto node in agentNodes)
{
expectedVisited.Add(node.Id);
}
if (!expectedVisited.SetEquals(visited))
{
AddGraphIssue(issues, "Linear orchestration graphs must visit every agent exactly once.");
}
}
private static void ValidateConcurrentGraph(
PatternDefinitionDto pattern,
PatternGraphDto graph,
List<PatternValidationIssueDto> issues)
{
ValidateSystemNodeCounts(graph, ["user-input", "distributor", "collector", "user-output"], issues);
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
PatternGraphNodeDto? distributorNode = GetNodeByKind(graph, "distributor");
PatternGraphNodeDto? collectorNode = GetNodeByKind(graph, "collector");
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
if (inputNode is null || distributorNode is null || collectorNode is null || outputNode is null)
{
return;
}
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
HashSet<string> distributorTargets = outgoing.GetValueOrDefault(distributorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
HashSet<string> collectorSources = incoming.GetValueOrDefault(collectorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
{
AddGraphIssue(issues, "Concurrent orchestration graphs must fan out from the distributor and fan back into the collector.");
}
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
{
AddGraphIssue(issues, "User input must connect only to the distributor.");
}
if (incoming.GetValueOrDefault(distributorNode.Id, []).Count != 1)
{
AddGraphIssue(issues, "Distributor must receive exactly one edge from user input.");
}
if (outgoing.GetValueOrDefault(collectorNode.Id, []).Count != 1 || incoming.GetValueOrDefault(outputNode.Id, []).Count != 1)
{
AddGraphIssue(issues, "Collector must forward exactly one edge to user output.");
}
foreach (PatternGraphNodeDto agentNode in agentNodes)
{
if (!distributorTargets.Contains(agentNode.Id))
{
AddGraphIssue(issues, $"Distributor must connect to agent \"{agentNode.AgentId}\".");
}
if (!collectorSources.Contains(agentNode.Id))
{
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect to the collector.");
}
}
}
private static void ValidateHandoffGraph(
PatternGraphDto graph,
List<PatternValidationIssueDto> issues)
{
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
if (inputNode is null || outputNode is null)
{
return;
}
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
HashSet<string> agentNodeIds = agentNodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
List<PatternGraphEdgeDto> entryEdges = outgoing.GetValueOrDefault(inputNode.Id, []);
List<PatternGraphEdgeDto> completionEdges = incoming.GetValueOrDefault(outputNode.Id, []);
if (entryEdges.Count != 1)
{
AddGraphIssue(issues, "Handoff graphs must connect user input to exactly one entry agent.");
return;
}
if (!agentNodeIds.Contains(entryEdges[0].Target))
{
AddGraphIssue(issues, "Handoff entry edges must target an agent node.");
}
if (completionEdges.Count == 0)
{
AddGraphIssue(issues, "Handoff graphs must allow at least one agent to complete back to user output.");
}
bool hasAgentToAgentRoute = false;
foreach (PatternGraphEdgeDto edge in graph.Edges)
{
if (Comparer.Equals(edge.Source, inputNode.Id))
{
continue;
}
if (Comparer.Equals(edge.Target, outputNode.Id))
{
if (!agentNodeIds.Contains(edge.Source))
{
AddGraphIssue(issues, "Only agent nodes may complete to user output.");
}
continue;
}
if (!agentNodeIds.Contains(edge.Source) || !agentNodeIds.Contains(edge.Target))
{
AddGraphIssue(issues, "Handoff routes may only connect agents to agents or agents to user output.");
continue;
}
if (Comparer.Equals(edge.Source, edge.Target))
{
AddGraphIssue(issues, "Handoff routes cannot target the same agent node.");
}
hasAgentToAgentRoute = true;
}
if (!hasAgentToAgentRoute && agentNodes.Count > 1)
{
AddGraphIssue(issues, "Handoff graphs must include at least one agent-to-agent handoff route.");
}
HashSet<string> reachable = new(StringComparer.Ordinal);
Stack<string> stack = new Stack<string>([entryEdges[0].Target]);
while (stack.Count > 0)
{
string nodeId = stack.Pop();
if (!reachable.Add(nodeId))
{
continue;
}
foreach (PatternGraphEdgeDto edge in outgoing.GetValueOrDefault(nodeId, []))
{
if (agentNodeIds.Contains(edge.Target) && !reachable.Contains(edge.Target))
{
stack.Push(edge.Target);
}
}
}
foreach (PatternGraphNodeDto agentNode in agentNodes)
{
if (!reachable.Contains(agentNode.Id))
{
AddGraphIssue(issues, $"Handoff entry agent must be able to reach \"{agentNode.AgentId}\".");
}
}
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
{
AddGraphIssue(issues, "User input cannot have incoming edges and user output cannot have outgoing edges.");
}
}
private static void ValidateGroupChatGraph(
PatternDefinitionDto pattern,
PatternGraphDto graph,
List<PatternValidationIssueDto> issues)
{
ValidateSystemNodeCounts(graph, ["user-input", "orchestrator", "user-output"], issues);
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
PatternGraphNodeDto? orchestratorNode = GetNodeByKind(graph, "orchestrator");
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
if (inputNode is null || orchestratorNode is null || outputNode is null)
{
return;
}
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
HashSet<string> orchestratorTargets = outgoing.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
HashSet<string> orchestratorSources = incoming.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
{
AddGraphIssue(issues, "Group chat graphs must connect the orchestrator to every participant and then back to user output.");
}
if (outgoing.GetValueOrDefault(inputNode.Id, []).Any(edge => !Comparer.Equals(edge.Target, orchestratorNode.Id)))
{
AddGraphIssue(issues, "User input must only connect to the orchestrator.");
}
if (!outgoing.GetValueOrDefault(orchestratorNode.Id, []).Any(edge => Comparer.Equals(edge.Target, outputNode.Id)))
{
AddGraphIssue(issues, "Group chat orchestrator must connect to user output.");
}
foreach (PatternGraphNodeDto agentNode in agentNodes)
{
if (!orchestratorTargets.Contains(agentNode.Id))
{
AddGraphIssue(issues, $"Orchestrator must connect to agent \"{agentNode.AgentId}\".");
}
if (!orchestratorSources.Contains(agentNode.Id))
{
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect back to the orchestrator.");
}
}
}
private static void ValidateSystemNodeCounts(
PatternGraphDto graph,
IReadOnlyList<string> expectedKinds,
List<PatternValidationIssueDto> issues)
{
Dictionary<string, int> counts = graph.Nodes
.GroupBy(node => node.Kind, Comparer)
.ToDictionary(group => group.Key, group => group.Count(), Comparer);
HashSet<string> expected = expectedKinds.ToHashSet(Comparer);
foreach (string kind in expectedKinds)
{
if (counts.GetValueOrDefault(kind, 0) != 1)
{
AddGraphIssue(issues, $"Pattern graph must include exactly one \"{kind}\" node.");
}
}
foreach ((string kind, int count) in counts)
{
if (Comparer.Equals(kind, "agent"))
{
continue;
}
if (!expected.Contains(kind) && count > 0)
{
AddGraphIssue(issues, $"Pattern graph does not allow \"{kind}\" nodes in this mode.");
}
}
}
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
private static List<PatternGraphNodeDto> GetAgentNodes(PatternGraphDto graph)
=> graph.Nodes.Where(node => Comparer.Equals(node.Kind, "agent")).ToList();
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildIncomingLookup(PatternGraphDto graph)
{
Dictionary<string, List<PatternGraphEdgeDto>> incoming = new(StringComparer.Ordinal);
foreach (PatternGraphNodeDto node in graph.Nodes)
{
incoming[node.Id] = [];
}
foreach (PatternGraphEdgeDto edge in graph.Edges)
{
if (!incoming.TryGetValue(edge.Target, out List<PatternGraphEdgeDto>? edges))
{
edges = [];
incoming[edge.Target] = edges;
}
edges.Add(edge);
}
return incoming;
}
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
{
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = new(StringComparer.Ordinal);
foreach (PatternGraphNodeDto node in graph.Nodes)
{
outgoing[node.Id] = [];
}
foreach (PatternGraphEdgeDto edge in graph.Edges)
{
if (!outgoing.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
{
edges = [];
outgoing[edge.Source] = edges;
}
edges.Add(edge);
}
return outgoing;
}
private static void AddGraphIssue(List<PatternValidationIssueDto> issues, string message)
=> issues.Add(new PatternValidationIssueDto
{
Field = "graph",
Message = message,
});
}
@@ -10,7 +10,7 @@ namespace Aryx.AgentHost.Services;
public sealed class SidecarProtocolHost
{
private const string DescribeCapabilitiesCommandType = "describe-capabilities";
private const string ValidatePatternCommandType = "validate-pattern";
private const string ValidateWorkflowCommandType = "validate-workflow";
private const string RunTurnCommandType = "run-turn";
private const string CancelTurnCommandType = "cancel-turn";
private const string ResolveApprovalCommandType = "resolve-approval";
@@ -41,7 +41,7 @@ public sealed class SidecarProtocolHost
];
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly PatternValidator _patternValidator;
private readonly WorkflowValidator _workflowValidator;
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions;
@@ -53,18 +53,26 @@ public sealed class SidecarProtocolHost
new(StringComparer.Ordinal);
public SidecarProtocolHost()
: this(new PatternValidator())
: this(new WorkflowValidator())
{
}
public SidecarProtocolHost(
PatternValidator patternValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
{
}
public SidecarProtocolHost(
WorkflowValidator workflowValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
{
_patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_workflowValidator = workflowValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_workflowValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_jsonOptions = JsonSerialization.CreateWebOptions();
@@ -73,7 +81,7 @@ public sealed class SidecarProtocolHost
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
{
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
[ValidatePatternCommandType] = HandleValidatePatternAsync,
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
[RunTurnCommandType] = HandleRunTurnAsync,
[CancelTurnCommandType] = HandleCancelTurnAsync,
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
@@ -168,15 +176,15 @@ public sealed class SidecarProtocolHost
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleValidatePatternAsync(CommandContext context)
private async Task HandleValidateWorkflowAsync(CommandContext context)
{
ValidatePatternCommandDto command = DeserializeCommand<ValidatePatternCommandDto>(context);
ValidateWorkflowCommandDto command = DeserializeCommand<ValidateWorkflowCommandDto>(context);
await WriteAsync(context.Output, new PatternValidationEventDto
await WriteAsync(context.Output, new WorkflowValidationEventDto
{
Type = "pattern-validation",
Type = "workflow-validation",
RequestId = context.Envelope.RequestId,
Issues = _patternValidator.Validate(command.Pattern),
Issues = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary),
}, context.CancellationToken).ConfigureAwait(false);
}
@@ -0,0 +1,432 @@
using System.Collections;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal static class WorkflowConditionEvaluator
{
private static readonly HashSet<string> SupportedConditionTypes = new(StringComparer.OrdinalIgnoreCase)
{
"always",
"message-type",
"expression",
"property",
};
private static readonly HashSet<string> SupportedOperators = new(StringComparer.OrdinalIgnoreCase)
{
"equals",
"not-equals",
"contains",
"gt",
"lt",
"regex",
};
private static readonly Regex ComparisonExpression = new(
@"^(?<path>[A-Za-z_][A-Za-z0-9_\.]*)\s*(?<operator>==|!=|>|<|contains|matches)\s*(?<value>""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
internal static bool IsSupportedConditionType(string? type)
=> !string.IsNullOrWhiteSpace(type) && SupportedConditionTypes.Contains(type);
internal static bool IsSupportedOperator(string? @operator)
=> !string.IsNullOrWhiteSpace(@operator) && SupportedOperators.Contains(@operator);
internal static bool IsSupportedExpression(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return false;
}
string trimmed = expression.Trim();
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
{
return true;
}
string? delimiter = trimmed.Contains("&&", StringComparison.Ordinal) ? "&&" : null;
if (delimiter is null && trimmed.Contains("||", StringComparison.Ordinal))
{
delimiter = "||";
}
if (delimiter is null)
{
return ComparisonExpression.IsMatch(trimmed);
}
return trimmed
.Split(delimiter, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.All(segment => ComparisonExpression.IsMatch(segment));
}
internal static Func<object?, bool>? Compile(WorkflowEdgeDto edge)
{
ArgumentNullException.ThrowIfNull(edge);
Func<object?, bool>? baseCondition = edge.Condition is null
? null
: CompileCondition(edge.Condition);
if (edge.IsLoop != true)
{
return baseCondition;
}
int maxIterations = edge.MaxIterations ?? 0;
int successfulIterations = 0;
return payload =>
{
if (successfulIterations >= maxIterations)
{
return false;
}
if (baseCondition is not null && !baseCondition(payload))
{
return false;
}
successfulIterations++;
return true;
};
}
internal static bool Evaluate(EdgeConditionDto condition, object? payload)
{
ArgumentNullException.ThrowIfNull(condition);
return CompileCondition(condition)?.Invoke(payload) ?? true;
}
private static Func<object?, bool>? CompileCondition(EdgeConditionDto condition)
{
if (string.IsNullOrWhiteSpace(condition.Type)
|| string.Equals(condition.Type, "always", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (string.Equals(condition.Type, "message-type", StringComparison.OrdinalIgnoreCase))
{
string expectedTypeName = condition.TypeName?.Trim() ?? string.Empty;
return payload =>
{
if (payload is null || string.IsNullOrWhiteSpace(expectedTypeName))
{
return false;
}
Type payloadType = payload.GetType();
return string.Equals(payloadType.Name, expectedTypeName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(payloadType.FullName, expectedTypeName, StringComparison.OrdinalIgnoreCase);
};
}
if (string.Equals(condition.Type, "property", StringComparison.OrdinalIgnoreCase))
{
string combinator = string.Equals(condition.Combinator, "or", StringComparison.OrdinalIgnoreCase) ? "or" : "and";
return payload =>
{
IReadOnlyList<bool> results = condition.Rules
.Select(rule => EvaluateRule(rule, payload))
.ToArray();
if (results.Count == 0)
{
return false;
}
return string.Equals(combinator, "or", StringComparison.OrdinalIgnoreCase)
? results.Any(result => result)
: results.All(result => result);
};
}
if (string.Equals(condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
{
return payload => EvaluateExpression(condition.Expression, payload);
}
throw new NotSupportedException($"Condition type \"{condition.Type}\" is not supported.");
}
private static bool EvaluateExpression(string? expression, object? payload)
{
string trimmed = expression?.Trim() ?? string.Empty;
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (trimmed.Contains("&&", StringComparison.Ordinal))
{
return trimmed
.Split("&&", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.All(segment => EvaluateExpression(segment, payload));
}
if (trimmed.Contains("||", StringComparison.Ordinal))
{
return trimmed
.Split("||", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Any(segment => EvaluateExpression(segment, payload));
}
Match match = ComparisonExpression.Match(trimmed);
if (!match.Success)
{
return false;
}
string path = match.Groups["path"].Value;
string @operator = match.Groups["operator"].Value switch
{
"==" => "equals",
"!=" => "not-equals",
">" => "gt",
"<" => "lt",
"matches" => "regex",
_ => match.Groups["operator"].Value,
};
string rawValue = match.Groups["value"].Value;
string value = UnwrapLiteral(rawValue);
return EvaluateRule(
new WorkflowConditionRuleDto
{
PropertyPath = path,
Operator = @operator,
Value = value,
},
payload);
}
private static bool EvaluateRule(WorkflowConditionRuleDto rule, object? payload)
{
if (payload is null || string.IsNullOrWhiteSpace(rule.PropertyPath))
{
return false;
}
if (!TryResolvePropertyPath(payload, rule.PropertyPath, out object? actualValue))
{
return false;
}
return rule.Operator switch
{
"equals" => AreEqual(actualValue, rule.Value),
"not-equals" => !AreEqual(actualValue, rule.Value),
"contains" => ContainsValue(actualValue, rule.Value),
"gt" => CompareAsNumberOrString(actualValue, rule.Value) > 0,
"lt" => CompareAsNumberOrString(actualValue, rule.Value) < 0,
"regex" => Regex.IsMatch(CoerceToString(actualValue), rule.Value, RegexOptions.CultureInvariant),
_ => false,
};
}
private static bool TryResolvePropertyPath(object payload, string propertyPath, out object? value)
{
object? current = payload;
foreach (string segment in propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!TryResolvePropertySegment(current, segment, out current))
{
value = null;
return false;
}
}
value = current;
return true;
}
private static bool TryResolvePropertySegment(object? current, string segment, out object? value)
{
value = null;
if (current is null)
{
return false;
}
if (current is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty jsonProperty in jsonElement.EnumerateObject())
{
if (string.Equals(jsonProperty.Name, segment, StringComparison.OrdinalIgnoreCase))
{
value = jsonProperty.Value;
return true;
}
}
}
return false;
}
if (current is IDictionary dictionary)
{
foreach (DictionaryEntry entry in dictionary)
{
if (entry.Key is string key && string.Equals(key, segment, StringComparison.OrdinalIgnoreCase))
{
value = entry.Value;
return true;
}
}
}
Type type = current.GetType();
PropertyInfo? property = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(candidate => string.Equals(candidate.Name, segment, StringComparison.OrdinalIgnoreCase));
if (property is not null)
{
value = property.GetValue(current);
return true;
}
return false;
}
private static bool AreEqual(object? actualValue, string expectedValue)
{
if (actualValue is null)
{
return false;
}
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
{
return actualDecimal == expectedDecimal;
}
return string.Equals(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsValue(object? actualValue, string expectedValue)
{
if (actualValue is null)
{
return false;
}
if (actualValue is string actualString)
{
return actualString.Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
}
if (actualValue is IEnumerable enumerable)
{
foreach (object? item in enumerable)
{
if (string.Equals(CoerceToString(item), expectedValue, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return CoerceToString(actualValue).Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
}
private static int CompareAsNumberOrString(object? actualValue, string expectedValue)
{
if (actualValue is null)
{
return -1;
}
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
{
return actualDecimal.CompareTo(expectedDecimal);
}
return string.Compare(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
}
private static bool TryConvertToDecimal(object? value, out decimal result)
{
switch (value)
{
case byte byteValue:
result = byteValue;
return true;
case short shortValue:
result = shortValue;
return true;
case int intValue:
result = intValue;
return true;
case long longValue:
result = longValue;
return true;
case float floatValue:
result = (decimal)floatValue;
return true;
case double doubleValue:
result = (decimal)doubleValue;
return true;
case decimal decimalValue:
result = decimalValue;
return true;
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetDecimal(out decimal jsonDecimal):
result = jsonDecimal;
return true;
default:
return decimal.TryParse(
CoerceToString(value),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out result);
}
}
private static string CoerceToString(object? value)
{
if (value is null)
{
return string.Empty;
}
if (value is JsonElement jsonElement)
{
return jsonElement.ValueKind switch
{
JsonValueKind.String => jsonElement.GetString() ?? string.Empty,
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
_ => jsonElement.ToString(),
};
}
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
}
private static string UnwrapLiteral(string rawValue)
{
if (rawValue.Length >= 2
&& ((rawValue.StartsWith('"') && rawValue.EndsWith('"'))
|| (rawValue.StartsWith('\'') && rawValue.EndsWith('\''))))
{
return rawValue[1..^1];
}
return rawValue;
}
}
@@ -0,0 +1,156 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal static class WorkflowDefinitionExtensions
{
public static IReadOnlyList<WorkflowNodeDto> GetAgentNodes(this WorkflowDefinitionDto workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
return workflow.Graph.Nodes
.Where(IsAgentNode)
.ToList();
}
public static IReadOnlyList<WorkflowNodeDto> GetAllAgentNodes(
this WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflow);
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = CreateWorkflowLibraryMap(workflowLibrary);
return GetAllAgentNodes(workflow, workflowLibraryMap);
}
public static IReadOnlyList<WorkflowNodeDto> GetAllAgentNodes(
this WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
List<WorkflowNodeDto> agentNodes = [];
CollectAgentNodes(
workflow,
workflowLibrary ?? EmptyWorkflowLibrary,
agentNodes,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
return agentNodes;
}
public static bool IsAgentNode(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
}
public static string GetAgentId(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
return !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
}
public static string GetAgentName(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
return FirstNonBlank(node.Config.Name, node.Label, node.Id) ?? "agent";
}
public static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
if (node.Config.InlineWorkflow is not null)
{
return node.Config.InlineWorkflow;
}
if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
&& workflowLibrary is not null
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
{
return workflow;
}
throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
}
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentException.ThrowIfNullOrWhiteSpace(mode);
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
}
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
private static void CollectAgentNodes(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
List<WorkflowNodeDto> agentNodes,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (node.IsAgentNode())
{
agentNodes.Add(node);
continue;
}
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
{
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
}
}
private static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
{
return workflowLibrary?
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? FirstNonBlank(params string?[] values)
{
foreach (string? value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
}
@@ -0,0 +1,858 @@
using System.Globalization;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class WorkflowOutputMessagesExecutor(string id = "OutputMessages")
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "OutputMessages";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
.AddHandler<TurnToken>(static (_, _, _) => default)
.AddHandler<ChatMessage>(YieldMessageAsync)
.AddHandler<List<ChatMessage>>(YieldMessagesAsync)
.AddHandler<ChatMessage[]>(YieldMessageArrayAsync)
.AddHandler<IEnumerable<ChatMessage>>(YieldEnumerableMessagesAsync)
.AddCatchAll(YieldCatchAllAsync))
.YieldsOutput<List<ChatMessage>>();
}
private static ValueTask YieldMessageAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.YieldOutputAsync(new List<ChatMessage> { message }, cancellationToken);
private static ValueTask YieldMessagesAsync(
List<ChatMessage> messages,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.YieldOutputAsync(messages, cancellationToken);
private static ValueTask YieldMessageArrayAsync(
ChatMessage[] messages,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.YieldOutputAsync(messages.ToList(), cancellationToken);
private static ValueTask YieldEnumerableMessagesAsync(
IEnumerable<ChatMessage> messages,
IWorkflowContext context,
CancellationToken cancellationToken)
=> context.YieldOutputAsync(messages.ToList(), cancellationToken);
private static ValueTask YieldCatchAllAsync(
PortableValue message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (message.Is<TurnToken>())
{
return default;
}
object payload = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
return context.YieldOutputAsync(
WorkflowValueSerializer.ToOutputMessages(payload),
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();
}
internal sealed class WorkflowStateScopeCatalog
{
public static WorkflowStateScopeCatalog Empty { get; } = new([]);
private readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, JsonElement>> _scopes;
public WorkflowStateScopeCatalog(IReadOnlyList<WorkflowStateScopeDto>? stateScopes)
{
Dictionary<string, IReadOnlyDictionary<string, JsonElement>> scopes = new(StringComparer.OrdinalIgnoreCase);
foreach (WorkflowStateScopeDto scope in stateScopes ?? [])
{
string? scopeName = NormalizeOptionalString(scope.Name);
if (scopeName is null)
{
continue;
}
Dictionary<string, JsonElement> initialValues = new(StringComparer.OrdinalIgnoreCase);
foreach ((string key, JsonElement value) in scope.InitialValues
?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase))
{
string? normalizedKey = NormalizeOptionalString(key);
if (normalizedKey is null)
{
continue;
}
initialValues[normalizedKey] = WorkflowValueSerializer.CloneElement(value);
}
scopes[scopeName] = initialValues;
}
_scopes = scopes;
}
public async ValueTask<JsonElement?> ReadJsonStateAsync(
IWorkflowContext context,
string scopeName,
string key,
CancellationToken cancellationToken)
{
string normalizedScope = NormalizeRequired(scopeName, nameof(scopeName));
string normalizedKey = NormalizeRequired(key, nameof(key));
if (TryGetInitialValue(normalizedScope, normalizedKey, out JsonElement initialValue))
{
JsonElement value = await context.ReadOrInitStateAsync(
normalizedKey,
() => WorkflowValueSerializer.CloneElement(initialValue),
normalizedScope,
cancellationToken).ConfigureAwait(false);
return WorkflowValueSerializer.CloneElement(value);
}
JsonElement? existing = await context.ReadStateAsync<JsonElement>(
normalizedKey,
normalizedScope,
cancellationToken).ConfigureAwait(false);
return existing.HasValue ? WorkflowValueSerializer.CloneElement(existing.Value) : null;
}
public ValueTask QueueJsonStateUpdateAsync(
IWorkflowContext context,
string scopeName,
string key,
JsonElement value,
CancellationToken cancellationToken)
{
string normalizedScope = NormalizeRequired(scopeName, nameof(scopeName));
string normalizedKey = NormalizeRequired(key, nameof(key));
return context.QueueStateUpdateAsync(
normalizedKey,
WorkflowValueSerializer.CloneElement(value),
normalizedScope,
cancellationToken);
}
private bool TryGetInitialValue(string scopeName, string key, out JsonElement value)
{
value = default;
return _scopes.TryGetValue(scopeName, out IReadOnlyDictionary<string, JsonElement>? scope)
&& scope.TryGetValue(key, out value);
}
private static string NormalizeRequired(string value, string paramName)
{
return NormalizeOptionalString(value)
?? throw new InvalidOperationException($"{paramName} is required.");
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed record WorkflowRequestPortNodeDefinition(
string NodeId,
string NodeLabel,
string PortId,
string RequestType,
string ResponseType,
string? Prompt);
internal sealed class WorkflowRequestPortPromptRequest
{
public string NodeId { get; init; } = string.Empty;
public string NodeLabel { get; init; } = string.Empty;
public string PortId { get; init; } = string.Empty;
public string RequestType { get; init; } = string.Empty;
public string ResponseType { get; init; } = string.Empty;
public string? Prompt { get; init; }
public string? InputSummary { get; init; }
}
internal sealed class WorkflowCodeExecutor(
string id,
string implementation,
WorkflowStateScopeCatalog stateCatalog)
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
private readonly string _implementation = implementation;
private readonly WorkflowStateScopeCatalog _stateCatalog = stateCatalog;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
.AddHandler<TurnToken>(static (_, _, _) => default)
.AddCatchAll(HandleAsync))
.SendsMessage<object>();
}
private async ValueTask HandleAsync(
PortableValue message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
object input = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
object result = await ExecuteAsync(input, context, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private ValueTask<object> ExecuteAsync(
object input,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (string.Equals(_implementation, "return-input", StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(input);
}
if (_implementation.StartsWith("return-text:", StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult<object>(_implementation["return-text:".Length..]);
}
if (_implementation.StartsWith("return-json:", StringComparison.OrdinalIgnoreCase))
{
string rawJson = _implementation["return-json:".Length..];
return ValueTask.FromResult<object>(WorkflowValueSerializer.ParseJsonElement(rawJson));
}
if (_implementation.StartsWith("state:set:", StringComparison.OrdinalIgnoreCase))
{
return ExecuteStateSetAsync(
_implementation.Split(':', 5),
context,
cancellationToken);
}
if (_implementation.StartsWith("state:get:", StringComparison.OrdinalIgnoreCase))
{
return ExecuteStateGetAsync(
_implementation.Split(':', 4),
context,
cancellationToken);
}
throw new InvalidOperationException(
$"Code executor \"{Id}\" does not support implementation \"{_implementation}\". " +
"Supported implementations are return-input, return-text:<text>, return-json:<json>, state:set:<scope>:<key>:<json>, and state:get:<scope>:<key>.");
}
private async ValueTask<object> ExecuteStateSetAsync(
string[] segments,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (segments.Length != 5)
{
throw new InvalidOperationException(
$"Code executor \"{Id}\" requires the format state:set:<scope>:<key>:<json>. Received \"{_implementation}\".");
}
JsonElement value = WorkflowValueSerializer.ParseJsonElement(segments[4]);
await _stateCatalog.QueueJsonStateUpdateAsync(
context,
segments[2],
segments[3],
value,
cancellationToken).ConfigureAwait(false);
return value;
}
private async ValueTask<object> ExecuteStateGetAsync(
string[] segments,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (segments.Length != 4)
{
throw new InvalidOperationException(
$"Code executor \"{Id}\" requires the format state:get:<scope>:<key>. Received \"{_implementation}\".");
}
JsonElement? value = await _stateCatalog.ReadJsonStateAsync(
context,
segments[2],
segments[3],
cancellationToken).ConfigureAwait(false);
return value ?? WorkflowValueSerializer.CreateNullElement();
}
public ValueTask ResetAsync() => default;
}
internal sealed class WorkflowFunctionExecutor(
string id,
string functionRef,
IReadOnlyDictionary<string, JsonElement>? parameters,
WorkflowStateScopeCatalog stateCatalog)
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
private readonly string _functionRef = functionRef;
private readonly IReadOnlyDictionary<string, JsonElement> _parameters = parameters ?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
private readonly WorkflowStateScopeCatalog _stateCatalog = stateCatalog;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
.AddHandler<TurnToken>(static (_, _, _) => default)
.AddCatchAll(HandleAsync))
.SendsMessage<object>();
}
private async ValueTask HandleAsync(
PortableValue message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
object input = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
object result = await WorkflowFunctionRegistry.InvokeAsync(
_functionRef,
input,
_parameters,
context,
_stateCatalog,
cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public ValueTask ResetAsync() => default;
}
internal static class WorkflowFunctionRegistry
{
private static readonly HashSet<string> SupportedFunctionRefs = new(StringComparer.OrdinalIgnoreCase)
{
"identity",
"return-parameter",
"concat-text",
"state:get",
"state:set",
};
public static bool IsSupported(string? functionRef)
=> !string.IsNullOrWhiteSpace(functionRef) && SupportedFunctionRefs.Contains(functionRef.Trim());
public static async ValueTask<object> InvokeAsync(
string functionRef,
object input,
IReadOnlyDictionary<string, JsonElement> parameters,
IWorkflowContext context,
WorkflowStateScopeCatalog stateCatalog,
CancellationToken cancellationToken)
{
string normalizedFunctionRef = functionRef.Trim();
return normalizedFunctionRef switch
{
var value when string.Equals(value, "identity", StringComparison.OrdinalIgnoreCase)
=> input,
var value when string.Equals(value, "return-parameter", StringComparison.OrdinalIgnoreCase)
=> ReturnParameter(parameters),
var value when string.Equals(value, "concat-text", StringComparison.OrdinalIgnoreCase)
=> ConcatText(input, parameters),
var value when string.Equals(value, "state:get", StringComparison.OrdinalIgnoreCase)
=> await GetStateAsync(parameters, context, stateCatalog, cancellationToken).ConfigureAwait(false),
var value when string.Equals(value, "state:set", StringComparison.OrdinalIgnoreCase)
=> await SetStateAsync(parameters, context, stateCatalog, cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException(
$"Function executor references unsupported functionRef \"{functionRef}\". Supported refs are: {string.Join(", ", SupportedFunctionRefs.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase))}.")
};
}
private static object ReturnParameter(IReadOnlyDictionary<string, JsonElement> parameters)
{
if (TryGetParameter(parameters, "name", out JsonElement namedParameterSelector)
&& namedParameterSelector.ValueKind == JsonValueKind.String)
{
string parameterName = namedParameterSelector.GetString() ?? string.Empty;
if (TryGetParameter(parameters, parameterName, out JsonElement namedValue))
{
return WorkflowValueSerializer.CloneElement(namedValue);
}
throw new InvalidOperationException(
$"Function executor return-parameter could not find parameter \"{parameterName}\".");
}
if (TryGetParameter(parameters, "value", out JsonElement value))
{
return WorkflowValueSerializer.CloneElement(value);
}
KeyValuePair<string, JsonElement>[] remaining = parameters
.Where(static pair => !string.Equals(pair.Key, "name", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (remaining.Length == 1)
{
return WorkflowValueSerializer.CloneElement(remaining[0].Value);
}
throw new InvalidOperationException(
"Function executor return-parameter requires either a value parameter, a name selector, or exactly one parameter value.");
}
private static object ConcatText(object input, IReadOnlyDictionary<string, JsonElement> parameters)
{
List<string> parts = [];
if (TryGetString(parameters, "prefix", out string? prefix))
{
parts.Add(prefix!);
}
bool includeInput = !TryGetBoolean(parameters, "includeInput", out bool parsedIncludeInput) || parsedIncludeInput;
if (includeInput)
{
parts.Add(WorkflowValueSerializer.ToDisplayText(input));
}
if (TryGetParameter(parameters, "values", out JsonElement values))
{
if (values.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("Function executor concat-text requires values to be a JSON array when provided.");
}
foreach (JsonElement element in values.EnumerateArray())
{
parts.Add(WorkflowValueSerializer.ToDisplayText(element));
}
}
if (TryGetString(parameters, "suffix", out string? suffix))
{
parts.Add(suffix!);
}
string separator = TryGetString(parameters, "separator", out string? parsedSeparator)
? parsedSeparator!
: string.Empty;
return string.Join(separator, parts);
}
private static async ValueTask<object> GetStateAsync(
IReadOnlyDictionary<string, JsonElement> parameters,
IWorkflowContext context,
WorkflowStateScopeCatalog stateCatalog,
CancellationToken cancellationToken)
{
string scope = GetRequiredString(parameters, "scope");
string key = GetRequiredString(parameters, "key");
JsonElement? value = await stateCatalog.ReadJsonStateAsync(
context,
scope,
key,
cancellationToken).ConfigureAwait(false);
return value ?? WorkflowValueSerializer.CreateNullElement();
}
private static async ValueTask<object> SetStateAsync(
IReadOnlyDictionary<string, JsonElement> parameters,
IWorkflowContext context,
WorkflowStateScopeCatalog stateCatalog,
CancellationToken cancellationToken)
{
string scope = GetRequiredString(parameters, "scope");
string key = GetRequiredString(parameters, "key");
JsonElement value = GetRequiredJson(parameters, "value");
await stateCatalog.QueueJsonStateUpdateAsync(
context,
scope,
key,
value,
cancellationToken).ConfigureAwait(false);
return value;
}
private static string GetRequiredString(IReadOnlyDictionary<string, JsonElement> parameters, string name)
{
if (TryGetString(parameters, name, out string? value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new InvalidOperationException($"Function executor requires a non-empty string parameter \"{name}\".");
}
private static JsonElement GetRequiredJson(IReadOnlyDictionary<string, JsonElement> parameters, string name)
{
if (TryGetParameter(parameters, name, out JsonElement value))
{
return WorkflowValueSerializer.CloneElement(value);
}
throw new InvalidOperationException($"Function executor requires parameter \"{name}\".");
}
private static bool TryGetString(IReadOnlyDictionary<string, JsonElement> parameters, string name, out string? value)
{
value = null;
if (!TryGetParameter(parameters, name, out JsonElement element))
{
return false;
}
value = element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
JsonValueKind.Number => element.ToString(),
_ => null,
};
return value is not null;
}
private static bool TryGetBoolean(IReadOnlyDictionary<string, JsonElement> parameters, string name, out bool value)
{
value = false;
if (!TryGetParameter(parameters, name, out JsonElement element))
{
return false;
}
if (element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False)
{
value = element.GetBoolean();
return true;
}
if (element.ValueKind == JsonValueKind.String && bool.TryParse(element.GetString(), out bool parsed))
{
value = parsed;
return true;
}
return false;
}
private static bool TryGetParameter(IReadOnlyDictionary<string, JsonElement> parameters, string name, out JsonElement value)
{
foreach ((string key, JsonElement parameterValue) in parameters)
{
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
{
value = parameterValue;
return true;
}
}
value = default;
return false;
}
}
internal sealed class WorkflowRequestPortIngressExecutor(
WorkflowRequestPortNodeDefinition definition,
RequestPort port)
: Executor($"{definition.NodeId}::request-entry", declareCrossRunShareable: true), IResettableExecutor
{
private readonly WorkflowRequestPortNodeDefinition _definition = definition;
private readonly RequestPort _port = port;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
.AddHandler<TurnToken>(static (_, _, _) => default)
.AddCatchAll(HandleAsync))
.SendsMessage<WorkflowRequestPortPromptRequest>();
}
private async ValueTask HandleAsync(
PortableValue message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
object input = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
WorkflowRequestPortPromptRequest request = new()
{
NodeId = _definition.NodeId,
NodeLabel = _definition.NodeLabel,
PortId = _definition.PortId,
RequestType = _definition.RequestType,
ResponseType = _definition.ResponseType,
Prompt = _definition.Prompt,
InputSummary = WorkflowValueSerializer.ToPromptSummary(input),
};
await context.SendMessageAsync(request, _port.Id, cancellationToken).ConfigureAwait(false);
}
public ValueTask ResetAsync() => default;
}
internal sealed class WorkflowRequestPortResponseExecutor(string nodeId)
: Executor($"{nodeId}::request-exit", declareCrossRunShareable: true), IResettableExecutor
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder
.AddHandler<TurnToken>(static (_, _, _) => default)
.AddHandler<ExternalResponse>(static (_, _, _) => default)
.AddCatchAll(ForwardAsync))
.SendsMessage<object>();
}
private static ValueTask ForwardAsync(
PortableValue message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
object payload = message.As<object>() ?? WorkflowValueSerializer.CreateNullElement();
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
}
public ValueTask ResetAsync() => default;
}
internal static class WorkflowValueSerializer
{
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
public static JsonElement CloneElement(JsonElement value) => value.Clone();
public static JsonElement ParseJsonElement(string json)
{
try
{
return JsonDocument.Parse(json).RootElement.Clone();
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Invalid JSON payload: {ex.Message}", ex);
}
}
public static JsonElement CreateNullElement() => JsonDocument.Parse("null").RootElement.Clone();
public static List<ChatMessage> ToOutputMessages(object value)
{
if (value is List<ChatMessage> chatMessages)
{
return chatMessages;
}
if (value is ChatMessage chatMessage)
{
return [chatMessage];
}
if (value is ChatMessage[] chatMessageArray)
{
return [.. chatMessageArray];
}
if (value is IEnumerable<ChatMessage> enumerable)
{
return enumerable.ToList();
}
return [
new ChatMessage(ChatRole.Assistant, ToDisplayText(value))
{
AuthorName = "Workflow",
},
];
}
public static string ToDisplayText(object? value)
{
if (value is null)
{
return "null";
}
if (value is string text)
{
return text;
}
if (value is JsonElement jsonElement)
{
return jsonElement.ValueKind switch
{
JsonValueKind.String => jsonElement.GetString() ?? string.Empty,
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
JsonValueKind.Number => jsonElement.ToString(),
JsonValueKind.Null => "null",
_ => jsonElement.GetRawText(),
};
}
if (value is ChatMessage chatMessage)
{
return chatMessage.Text ?? string.Empty;
}
if (value is IEnumerable<ChatMessage> messages)
{
return string.Join(Environment.NewLine, messages.Select(static message => message.Text ?? string.Empty));
}
if (value is bool boolean)
{
return boolean ? bool.TrueString : bool.FalseString;
}
if (value is IFormattable formattable)
{
return formattable.ToString(null, CultureInfo.InvariantCulture);
}
return JsonSerializer.Serialize(value, JsonOptions);
}
public static string? ToPromptSummary(object? value)
{
if (value is null || value is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Null)
{
return null;
}
string summary = ToDisplayText(value);
return string.IsNullOrWhiteSpace(summary) ? null : summary;
}
}
@@ -12,6 +12,8 @@ internal static class WorkflowRequestInfoInterpreter
private const string ToolCallingActivityType = "tool-calling";
private const string CodeInterpreterToolName = "code interpreter";
private const string ImageGenerationToolName = "image generation";
private const int MaxToolArgumentValueLength = 4000;
private const string TruncatedToolArgumentValue = "[truncated]";
private static readonly JsonSerializerOptions JsonOptions = JsonSerialization.CreateWebOptions();
public static AgentActivityEventDto? TryCreateActivityFromRequest(
@@ -20,7 +22,7 @@ internal static class WorkflowRequestInfoInterpreter
AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId)
{
RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo);
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo);
return interpretation switch
{
HandoffRequestInterpretation handoff =>
@@ -35,8 +37,8 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
RequestInfoEvent requestInfo)
{
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)
&& InterpretRequest(command.Pattern, requestInfo) is UnknownRequestInterpretation;
return command.Workflow.IsOrchestrationMode("handoff")
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation;
}
private static AgentActivityEventDto CreateHandoffActivity(
@@ -57,12 +59,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
@@ -75,6 +82,7 @@ internal static class WorkflowRequestInfoInterpreter
AgentName = activeAgent.AgentName,
ToolName = tool.ToolName,
ToolCallId = tool.ToolCallId,
ToolArguments = tool.ToolArguments,
};
}
@@ -90,21 +98,21 @@ internal static class WorkflowRequestInfoInterpreter
}
private static RequestInterpretation InterpretRequest(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
RequestInfoEvent requestInfo)
{
if (TryGetHandoffTarget(pattern, requestInfo, out AgentIdentity handoffAgent))
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent))
{
return new HandoffRequestInterpretation(handoffAgent);
}
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId)
? new ToolRequestInterpretation(toolName, toolCallId)
return TryGetToolRequestInfo(requestInfo, out string toolName, out string? toolCallId, out IReadOnlyDictionary<string, object?>? toolArguments)
? new ToolRequestInterpretation(toolName, toolCallId, toolArguments)
: new UnknownRequestInterpretation();
}
private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
RequestInfoEvent requestInfo,
out AgentIdentity agent)
{
@@ -123,7 +131,7 @@ internal static class WorkflowRequestInfoInterpreter
}
agent = AgentIdentityResolver.ResolveAgentIdentity(
pattern,
workflow,
target.Id,
target.Name);
return !string.IsNullOrWhiteSpace(agent.AgentName);
@@ -132,33 +140,38 @@ internal static class WorkflowRequestInfoInterpreter
private static bool TryGetToolRequestInfo(
RequestInfoEvent requestInfo,
out string toolName,
out string? toolCallId)
out string? toolCallId,
out IReadOnlyDictionary<string, object?>? toolArguments)
{
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId)
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId);
return TryGetStableToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments)
|| TryGetEvaluationToolRequestInfo(requestInfo.Request.Data, out toolName, out toolCallId, out toolArguments);
}
private static bool TryGetStableToolRequestInfo(
PortableValue requestData,
out string toolName,
out string? toolCallId)
out string? toolCallId,
out IReadOnlyDictionary<string, object?>? toolArguments)
{
if (requestData.Is<FunctionCallContent>(out FunctionCallContent? functionCall))
{
toolName = NormalizeOptionalString(functionCall.Name) ?? "function";
toolCallId = NormalizeOptionalString(functionCall.CallId);
toolArguments = NormalizeToolArguments(functionCall.Arguments);
return true;
}
toolName = string.Empty;
toolCallId = null;
toolArguments = null;
return false;
}
private static bool TryGetEvaluationToolRequestInfo(
PortableValue requestData,
out string toolName,
out string? toolCallId)
out string? toolCallId,
out IReadOnlyDictionary<string, object?>? toolArguments)
{
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
{
@@ -166,6 +179,7 @@ internal static class WorkflowRequestInfoInterpreter
?? NormalizeOptionalString(mcpToolCall.ServerName)
?? string.Empty;
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
toolArguments = NormalizeToolArguments(mcpToolCall.Arguments);
return toolName.Length > 0;
}
@@ -173,6 +187,7 @@ internal static class WorkflowRequestInfoInterpreter
{
toolName = CodeInterpreterToolName;
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
return true;
}
@@ -180,14 +195,196 @@ internal static class WorkflowRequestInfoInterpreter
{
toolName = ImageGenerationToolName;
toolCallId = null;
toolArguments = null;
return true;
}
toolName = string.Empty;
toolCallId = null;
toolArguments = null;
return false;
}
private static IReadOnlyDictionary<string, object?>? NormalizeToolArguments(
IEnumerable<KeyValuePair<string, object?>>? arguments)
{
if (arguments is null)
{
return null;
}
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, object?> argument in arguments)
{
string? key = NormalizeOptionalString(argument.Key);
if (key is null)
{
continue;
}
object? value = NormalizeToolArgumentValue(argument.Value);
if (value is null)
{
continue;
}
normalized[key] = value;
}
return normalized.Count > 0 ? normalized : null;
}
private static IReadOnlyDictionary<string, object?>? NormalizeCodeInterpreterToolArguments(
CodeInterpreterToolCallContent codeInterpreterToolCall)
{
IList<AIContent>? rawInputs = codeInterpreterToolCall.Inputs;
if (rawInputs is not { Count: > 0 })
{
return null;
}
List<object?> inputs = [];
foreach (AIContent input in rawInputs)
{
object? normalized = input switch
{
TextContent text => NormalizeToolArgumentValue(text.Text),
_ => BuildAiContentFallbackValue(input),
};
if (normalized is not null)
{
inputs.Add(normalized);
}
}
return inputs.Count > 0
? new Dictionary<string, object?>(StringComparer.Ordinal)
{
["inputs"] = inputs,
}
: null;
}
private static object? NormalizeToolArgumentValue(object? value)
{
return value switch
{
null => null,
string text => NormalizeToolArgumentText(text),
JsonElement element => NormalizeToolArgumentElement(element),
bool boolean => boolean,
byte number => number,
sbyte number => number,
short number => number,
ushort number => number,
int number => number,
uint number => number,
long number => number,
ulong number => number,
float number => number,
double number => number,
decimal number => number,
AIContent content => BuildAiContentFallbackValue(content),
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
IEnumerable<object?> sequence => NormalizeToolArgumentSequence(sequence),
_ => NormalizeUnknownToolArgumentValue(value),
};
}
private static object? NormalizeToolArgumentElement(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null or JsonValueKind.Undefined => null,
JsonValueKind.String => NormalizeToolArgumentText(element.GetString()),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.Deserialize<object?>(JsonOptions),
JsonValueKind.Object => NormalizeToolArgumentObject(element),
JsonValueKind.Array => NormalizeToolArgumentArray(element),
_ => NormalizeToolArgumentText(element.GetRawText()),
};
}
private static IReadOnlyDictionary<string, object?>? NormalizeToolArgumentObject(JsonElement element)
{
Dictionary<string, object?> normalized = new(StringComparer.Ordinal);
foreach (JsonProperty property in element.EnumerateObject())
{
string? key = NormalizeOptionalString(property.Name);
if (key is null)
{
continue;
}
object? value = NormalizeToolArgumentElement(property.Value);
if (value is not null)
{
normalized[key] = value;
}
}
return normalized.Count > 0 ? normalized : null;
}
private static IReadOnlyList<object?>? NormalizeToolArgumentArray(JsonElement element)
{
List<object?> normalized = [];
foreach (JsonElement item in element.EnumerateArray())
{
object? value = NormalizeToolArgumentElement(item);
if (value is not null)
{
normalized.Add(value);
}
}
return normalized.Count > 0 ? normalized : null;
}
private static IReadOnlyList<object?>? NormalizeToolArgumentSequence(IEnumerable<object?> sequence)
{
List<object?> normalized = [];
foreach (object? item in sequence)
{
object? value = NormalizeToolArgumentValue(item);
if (value is not null)
{
normalized.Add(value);
}
}
return normalized.Count > 0 ? normalized : null;
}
private static object? NormalizeUnknownToolArgumentValue(object value)
{
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
using JsonDocument document = JsonDocument.Parse(json);
return NormalizeToolArgumentElement(document.RootElement);
}
private static IReadOnlyDictionary<string, object?> BuildAiContentFallbackValue(AIContent content)
{
return new Dictionary<string, object?>(StringComparer.Ordinal)
{
["type"] = content.GetType().Name,
};
}
private static string? NormalizeToolArgumentText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Length > MaxToolArgumentValueLength
? TruncatedToolArgumentValue
: value;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -203,7 +400,10 @@ internal static class WorkflowRequestInfoInterpreter
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation;
private sealed record ToolRequestInterpretation(
string ToolName,
string? ToolCallId,
IReadOnlyDictionary<string, object?>? ToolArguments) : RequestInterpretation;
private sealed record UnknownRequestInterpretation : RequestInterpretation;
}
@@ -0,0 +1,224 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
namespace Aryx.AgentHost.Services;
internal sealed class WorkflowRunner
{
public Workflow BuildWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
List<string> agentIds = ResolveAgentIds(workflowDefinition, workflowLibraryMap);
Dictionary<string, AIAgent> agentMap = agentIds
.Zip(agents, (agentId, agent) => (agentId, agent))
.ToDictionary(pair => pair.agentId, pair => pair.agent, StringComparer.Ordinal);
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
}
private Workflow BuildWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, AIAgent> agentMap,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase));
WorkflowStateScopeCatalog stateCatalog = new(workflowDefinition.Settings.StateScopes);
Dictionary<string, WorkflowNodeRoute> routes = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
routes[node.Id] = CreateNodeRoute(node, agentMap, workflowLibrary, stateCatalog);
}
WorkflowBuilder builder = new(routes[startNode.Id].Entry);
foreach (WorkflowNodeRoute route in routes.Values)
{
foreach ((ExecutorBinding source, ExecutorBinding target) in route.InternalEdges)
{
builder.AddEdge(source, target);
}
}
foreach (WorkflowEdgeDto edge in workflowDefinition.Graph.Edges.Where(edge =>
string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase)))
{
Func<object?, bool>? condition = WorkflowConditionEvaluator.Compile(edge);
ExecutorBinding source = routes[edge.Source].Exit;
ExecutorBinding target = routes[edge.Target].Entry;
if (condition is null)
{
builder.AddEdge(source, target);
}
else
{
builder.AddEdge<object>(source, target, condition);
}
}
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflowDefinition.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
{
WorkflowEdgeDto[] fanOutEdges = fanOutGroup.ToArray();
ExecutorBinding source = routes[fanOutGroup.Key].Exit;
ExecutorBinding[] targets = fanOutEdges.Select(edge => routes[edge.Target].Entry).ToArray();
Func<object?, bool>?[] compiledConditions = fanOutEdges
.Select(WorkflowConditionEvaluator.Compile)
.ToArray();
bool hasConditionalRouting = fanOutEdges.Any(edge => edge.Condition is not null);
if (!hasConditionalRouting)
{
builder.AddFanOutEdge(source, targets);
continue;
}
builder.AddFanOutEdge<object>(
source,
targets,
(payload, _) => fanOutEdges
.Select((edge, index) => (edge, index))
.Where(pair => compiledConditions[pair.index]?.Invoke(payload) ?? true)
.Select(pair => pair.index)
.ToArray());
}
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflowDefinition.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
{
builder.AddFanInBarrierEdge(
fanInGroup.Select(edge => routes[edge.Source].Exit).ToArray(),
routes[fanInGroup.Key].Entry);
}
if (!string.IsNullOrWhiteSpace(workflowDefinition.Name))
{
builder = builder.WithName(workflowDefinition.Name);
}
return builder.WithOutputFrom(routes[endNode.Id].Exit).Build();
}
private WorkflowNodeRoute CreateNodeRoute(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentMap,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
WorkflowStateScopeCatalog stateCatalog)
{
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
{
ExecutorBinding binding = new ChatForwardingExecutor(node.Id).BindExecutor();
return new WorkflowNodeRoute(binding);
}
if (string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
{
ExecutorBinding binding = new WorkflowOutputMessagesExecutor(node.Id).BindExecutor();
return new WorkflowNodeRoute(binding);
}
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
{
string agentId = !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
if (!agentMap.TryGetValue(agentId, out AIAgent? agent))
{
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
}
return new WorkflowNodeRoute(agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()));
}
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
{
string implementation = NormalizeRequired(node.Config.Implementation, $"Workflow code executor \"{node.Id}\" requires an implementation.");
ExecutorBinding binding = new WorkflowCodeExecutor(node.Id, implementation, stateCatalog).BindExecutor();
return new WorkflowNodeRoute(binding);
}
if (string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase))
{
string functionRef = NormalizeRequired(node.Config.FunctionRef, $"Workflow function executor \"{node.Id}\" requires a functionRef.");
if (!WorkflowFunctionRegistry.IsSupported(functionRef))
{
throw new InvalidOperationException(
$"Workflow function executor \"{node.Id}\" references unsupported functionRef \"{functionRef}\".");
}
ExecutorBinding binding = new WorkflowFunctionExecutor(node.Id, functionRef, node.Config.Parameters, stateCatalog).BindExecutor();
return new WorkflowNodeRoute(binding);
}
if (string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase))
{
return CreateRequestPortRoute(node);
}
if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
{
WorkflowDefinitionDto subWorkflowDefinition = node.ResolveSubWorkflowDefinition(workflowLibrary);
Workflow subWorkflow = BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary);
return new WorkflowNodeRoute(subWorkflow.BindAsExecutor(node.Id));
}
throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet.");
}
private static WorkflowNodeRoute CreateRequestPortRoute(WorkflowNodeDto node)
{
WorkflowRequestPortNodeDefinition definition = new(
node.Id,
NormalizeOptionalString(node.Label) ?? node.Id,
NormalizeRequired(node.Config.PortId, $"Workflow request port \"{node.Id}\" requires a portId."),
NormalizeRequired(node.Config.RequestType, $"Workflow request port \"{node.Id}\" requires a requestType."),
NormalizeRequired(node.Config.ResponseType, $"Workflow request port \"{node.Id}\" requires a responseType."),
NormalizeOptionalString(node.Config.Prompt));
RequestPort port = new(definition.PortId, typeof(WorkflowRequestPortPromptRequest), typeof(object));
ExecutorBinding entry = new WorkflowRequestPortIngressExecutor(definition, port).BindExecutor();
ExecutorBinding portBinding = new RequestPortBinding(port, false);
ExecutorBinding exit = new WorkflowRequestPortResponseExecutor(node.Id).BindExecutor();
return new WorkflowNodeRoute(entry, exit, [(entry, portBinding), (portBinding, exit)]);
}
private static string NormalizeRequired(string? value, string errorMessage)
=> NormalizeOptionalString(value) ?? throw new InvalidOperationException(errorMessage);
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static List<string> ResolveAgentIds(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
return workflowDefinition.GetAllAgentNodes(workflowLibrary)
.Select(node => node.GetAgentId())
.ToList();
}
private sealed record WorkflowNodeRoute(
ExecutorBinding Entry,
ExecutorBinding Exit,
IReadOnlyList<(ExecutorBinding Source, ExecutorBinding Target)> InternalEdges)
{
public WorkflowNodeRoute(ExecutorBinding binding)
: this(binding, binding, [])
{
}
}
}
@@ -73,7 +73,7 @@ internal static class WorkflowTranscriptProjector
List<ChatMessageDto> projectedMessages = [];
int fallbackOutputIndex = 0;
string createdAt = DateTimeOffset.UtcNow.ToString("O");
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments);
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Workflow, segments);
List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
@@ -84,7 +84,7 @@ internal static class WorkflowTranscriptProjector
message,
remainingSegments,
assistantMessages.Count - messageIndex,
command.Pattern,
command.Workflow,
fallbackAgent);
string content = ResolveProjectedContent(message, matchedSegment);
if (string.IsNullOrWhiteSpace(content))
@@ -133,7 +133,7 @@ internal static class WorkflowTranscriptProjector
?? $"{command.RequestId}-final-{fallbackOutputIndex}",
Role = message.Role == ChatRole.System ? "system" : "assistant",
AuthorName = ResolveProjectedAuthorName(
command.Pattern,
command.Workflow,
message.AuthorName,
matchedSegment?.AuthorName,
fallbackAgent),
@@ -180,17 +180,17 @@ internal static class WorkflowTranscriptProjector
{
Id = segment.MessageId,
Role = "assistant",
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Workflow, segment.AuthorName),
Content = segment.Content,
CreatedAt = createdAt,
};
}
private static List<TranscriptSegment> PrepareSegmentsForProjection(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
IReadOnlyList<TranscriptSegment> segments)
{
if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal)
if (!workflow.IsOrchestrationMode("concurrent")
|| segments.Count <= 1)
{
return segments.ToList();
@@ -205,7 +205,7 @@ internal static class WorkflowTranscriptProjector
for (int index = 0; index < segments.Count; index++)
{
TranscriptSegment segment = segments[index];
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName);
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName);
latestSegmentByAuthor[authorKey] = (segment, index);
}
@@ -219,7 +219,7 @@ internal static class WorkflowTranscriptProjector
ChatMessage message,
IReadOnlyList<TranscriptSegment> remainingSegments,
int remainingMessageCount,
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
AgentIdentity? fallbackAgent)
{
if (remainingSegments.Count == 0)
@@ -231,7 +231,7 @@ internal static class WorkflowTranscriptProjector
if (messageText is not null)
{
string resolvedAuthorName = ResolveProjectedAuthorName(
pattern,
workflow,
message.AuthorName,
fallbackIdentifier: null,
fallbackAgent);
@@ -240,7 +240,7 @@ internal static class WorkflowTranscriptProjector
remainingSegments,
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
&& string.Equals(
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName),
resolvedAuthorName,
StringComparison.Ordinal),
out TranscriptSegment authorMatchedSegment))
@@ -264,7 +264,7 @@ internal static class WorkflowTranscriptProjector
&& TryFindLastSegment(
remainingSegments,
segment => string.Equals(
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName),
fallbackAgent.Value.AgentName,
StringComparison.Ordinal),
out TranscriptSegment fallbackMatchedSegment))
@@ -382,7 +382,7 @@ internal static class WorkflowTranscriptProjector
}
private static string ResolveProjectedAuthorName(
PatternDefinitionDto pattern,
WorkflowDefinitionDto workflow,
string? primaryIdentifier,
string? fallbackIdentifier,
AgentIdentity? fallbackAgent)
@@ -399,16 +399,17 @@ internal static class WorkflowTranscriptProjector
return fallbackAgent.Value.AgentName;
}
if (pattern.Agents.Count == 1
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
if (agentNodes.Count == 1
&& string.IsNullOrWhiteSpace(primaryIdentifier)
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
{
PatternAgentDefinitionDto singleAgent = pattern.Agents[0];
return AgentIdentityResolver.ResolveDisplayAuthorName(pattern, singleAgent.Id, singleAgent.Name);
WorkflowNodeDto singleAgent = agentNodes[0];
return AgentIdentityResolver.ResolveDisplayAuthorName(workflow, singleAgent.GetAgentId(), singleAgent.GetAgentName());
}
return AgentIdentityResolver.ResolveDisplayAuthorName(
pattern,
workflow,
primaryIdentifier,
fallbackIdentifier);
}
@@ -0,0 +1,709 @@
using System.Linq;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public sealed class WorkflowValidator
{
private static readonly HashSet<string> ExecutableNodeKinds = new(StringComparer.OrdinalIgnoreCase)
{
"start",
"end",
"agent",
"code-executor",
"function-executor",
"sub-workflow",
"request-port",
};
public IReadOnlyList<WorkflowValidationIssueDto> Validate(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
List<WorkflowValidationIssueDto> issues = [];
Dictionary<string, WorkflowDefinitionDto>? workflowLibraryById = workflowLibrary?
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(workflow.Name))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "name",
Message = "Workflow name is required.",
});
}
if (workflow.Graph.Nodes.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph",
Message = "Workflow graph must include nodes.",
});
return issues;
}
Dictionary<string, WorkflowNodeDto> nodesById = new(StringComparer.Ordinal);
HashSet<string> edgeIds = new(StringComparer.Ordinal);
Dictionary<string, int> incomingCounts = new(StringComparer.Ordinal);
Dictionary<string, int> outgoingCounts = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto node in workflow.Graph.Nodes)
{
if (string.IsNullOrWhiteSpace(node.Id))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.id",
Message = "Workflow nodes must have an ID.",
});
continue;
}
if (!nodesById.TryAdd(node.Id, node))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.id",
NodeId = node.Id,
Message = $"Workflow graph contains duplicate node \"{node.Id}\".",
});
continue;
}
if (!ExecutableNodeKinds.Contains(node.Kind))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.kind",
NodeId = node.Id,
Message = $"Workflow node kind \"{node.Kind}\" is not executable yet.",
});
}
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(node.Config.Name))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.name",
NodeId = node.Id,
Message = "Agent nodes require a name.",
});
}
if (string.IsNullOrWhiteSpace(node.Config.Model))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.model",
NodeId = node.Id,
Message = $"Agent node \"{node.Label}\" requires a model.",
});
}
}
ValidateExecutableNode(node, issues);
ValidateSubWorkflowNode(node, workflowLibraryById, issues);
}
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
{
if (string.IsNullOrWhiteSpace(edge.Id))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.id",
Message = "Workflow edges must have an ID.",
});
continue;
}
if (!edgeIds.Add(edge.Id))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.id",
EdgeId = edge.Id,
Message = $"Workflow graph contains duplicate edge \"{edge.Id}\".",
});
}
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
EdgeId = edge.Id,
Message = $"Workflow edge \"{edge.Id}\" must connect known nodes.",
});
continue;
}
outgoingCounts[edge.Source] = outgoingCounts.TryGetValue(edge.Source, out int outgoing)
? outgoing + 1
: 1;
incomingCounts[edge.Target] = incomingCounts.TryGetValue(edge.Target, out int incoming)
? incoming + 1
: 1;
ValidateEdgeCondition(edge, issues);
}
List<WorkflowNodeDto> startNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
.ToList();
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
.ToList();
List<WorkflowNodeDto> executableWorkNodes = workflow.Graph.Nodes
.Where(node =>
string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)
|| string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase)
|| string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase)
|| string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
|| string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
.ToList();
if (startNodes.Count != 1)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain exactly one start node.",
});
}
if (endNodes.Count != 1)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain exactly one end node.",
});
}
if (executableWorkNodes.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain at least one executable work node.",
});
}
foreach (WorkflowNodeDto startNode in startNodes)
{
if (incomingCounts.GetValueOrDefault(startNode.Id) != 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
NodeId = startNode.Id,
Message = "Start nodes cannot have incoming edges.",
});
}
if (outgoingCounts.GetValueOrDefault(startNode.Id) == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
NodeId = startNode.Id,
Message = "Start nodes must connect to at least one downstream node.",
});
}
}
foreach (WorkflowNodeDto endNode in endNodes)
{
if (outgoingCounts.GetValueOrDefault(endNode.Id) != 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
NodeId = endNode.Id,
Message = "End nodes cannot have outgoing edges.",
});
}
}
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflow.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
{
if (fanOutGroup.Count() < 2)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.kind",
NodeId = fanOutGroup.Key,
Message = "Fan-out edges require at least two outgoing fan-out connections from the same source.",
});
}
}
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflow.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
{
if (fanInGroup.Count() < 2)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.kind",
NodeId = fanInGroup.Key,
Message = "Fan-in edges require at least two incoming fan-in connections to the same target.",
});
}
}
WorkflowNodeDto? start = startNodes.FirstOrDefault();
if (start is not null && endNodes.Count > 0 && !HasPathToAnyEnd(start.Id, workflow.Graph, endNodes.Select(node => node.Id)))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
Message = "Workflow graph must include a path from the start node to at least one end node.",
});
}
if (workflow.Settings.MaxIterations is int workflowMaxIterations
&& (workflowMaxIterations < 1 || workflowMaxIterations > 100))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "settings.maxIterations",
Message = "Workflow maxIterations must be between 1 and 100.",
});
}
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
{
bool participatesInCycle = IsLoopEdge(workflow.Graph, edge);
if (!participatesInCycle)
{
if (edge.IsLoop == true)
{
issues.Add(new WorkflowValidationIssueDto
{
Level = "warning",
Field = "graph.edges.isLoop",
EdgeId = edge.Id,
Message = "This edge is marked as a loop but does not currently form a cycle.",
});
}
continue;
}
if (!string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.kind",
EdgeId = edge.Id,
Message = "Loop edges currently support only direct edges.",
});
}
if (edge.IsLoop != true)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.isLoop",
EdgeId = edge.Id,
Message = "Edges that participate in a cycle must be explicitly marked as loops.",
});
}
if ((edge.Condition is null || string.Equals(edge.Condition.Type, "always", StringComparison.OrdinalIgnoreCase))
&& (edge.MaxIterations is null || edge.MaxIterations < 1))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition",
EdgeId = edge.Id,
Message = "Loop edges require either a non-default condition or a maxIterations cap so the loop can terminate.",
});
}
if (edge.MaxIterations is null || edge.MaxIterations < 1)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.maxIterations",
EdgeId = edge.Id,
Message = "Loop edges require a maxIterations value of at least 1.",
});
}
HashSet<string> componentNodes = CollectStronglyConnectedNodes(workflow.Graph, edge.Source);
bool hasExitPath = workflow.Graph.Edges.Any(candidate =>
componentNodes.Contains(candidate.Source) && !componentNodes.Contains(candidate.Target));
if (!hasExitPath)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
EdgeId = edge.Id,
Message = "Loop cycles must include an exit path to a node outside the loop.",
});
}
}
return issues;
}
private void ValidateSubWorkflowNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibraryById,
List<WorkflowValidationIssueDto> issues)
{
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
{
return;
}
bool hasWorkflowId = !string.IsNullOrWhiteSpace(node.Config.WorkflowId);
bool hasInlineWorkflow = node.Config.InlineWorkflow is not null;
if (hasWorkflowId == hasInlineWorkflow)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config",
NodeId = node.Id,
Message = "Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.",
});
return;
}
if (hasWorkflowId
&& workflowLibraryById is not null
&& !workflowLibraryById.ContainsKey(node.Config.WorkflowId!))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.workflowId",
NodeId = node.Id,
Message = $"Sub-workflow node \"{node.Label}\" references unknown workflow \"{node.Config.WorkflowId}\".",
});
}
if (node.Config.InlineWorkflow is null)
{
return;
}
foreach (WorkflowValidationIssueDto inlineIssue in Validate(node.Config.InlineWorkflow, workflowLibraryById?.Values.ToList()))
{
issues.Add(new WorkflowValidationIssueDto
{
Level = inlineIssue.Level,
Field = inlineIssue.Field is null
? "graph.nodes.config.inlineWorkflow"
: $"graph.nodes.config.inlineWorkflow.{inlineIssue.Field}",
NodeId = node.Id,
EdgeId = inlineIssue.EdgeId,
Message = $"Inline workflow for node \"{node.Label}\": {inlineIssue.Message}",
});
}
}
private static void ValidateExecutableNode(
WorkflowNodeDto node,
List<WorkflowValidationIssueDto> issues)
{
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(node.Config.Implementation))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.implementation",
NodeId = node.Id,
Message = "Code executor nodes require a non-empty implementation.",
});
}
return;
}
if (string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(node.Config.FunctionRef))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.functionRef",
NodeId = node.Id,
Message = "Function executor nodes require a non-empty functionRef.",
});
}
return;
}
if (!string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (string.IsNullOrWhiteSpace(node.Config.PortId))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.portId",
NodeId = node.Id,
Message = "Request port nodes require a non-empty portId.",
});
}
if (string.IsNullOrWhiteSpace(node.Config.RequestType))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.requestType",
NodeId = node.Id,
Message = "Request port nodes require a non-empty requestType.",
});
}
if (string.IsNullOrWhiteSpace(node.Config.ResponseType))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.responseType",
NodeId = node.Id,
Message = "Request port nodes require a non-empty responseType.",
});
}
}
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
{
if (edge.Condition is null)
{
return;
}
if (string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition",
EdgeId = edge.Id,
Message = "Fan-in edges do not support conditions.",
});
}
if (!WorkflowConditionEvaluator.IsSupportedConditionType(edge.Condition.Type))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.type",
EdgeId = edge.Id,
Message = $"Condition type \"{edge.Condition.Type}\" is not supported.",
});
return;
}
if (string.Equals(edge.Condition.Type, "message-type", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(edge.Condition.TypeName))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.typeName",
EdgeId = edge.Id,
Message = "Message-type conditions require a type name.",
});
}
if (string.Equals(edge.Condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(edge.Condition.Expression))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.expression",
EdgeId = edge.Id,
Message = "Expression conditions require a non-empty expression.",
});
}
else if (!WorkflowConditionEvaluator.IsSupportedExpression(edge.Condition.Expression))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.expression",
EdgeId = edge.Id,
Message = "Expression conditions currently support simple comparisons using ==, !=, >, <, contains, matches, optionally combined with && or ||.",
});
}
}
if (string.Equals(edge.Condition.Type, "property", StringComparison.OrdinalIgnoreCase))
{
if (edge.Condition.Rules.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules",
EdgeId = edge.Id,
Message = "Property conditions require at least one rule.",
});
}
if (!string.IsNullOrWhiteSpace(edge.Condition.Combinator)
&& !string.Equals(edge.Condition.Combinator, "and", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(edge.Condition.Combinator, "or", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.combinator",
EdgeId = edge.Id,
Message = "Property conditions must use the \"and\" or \"or\" combinator.",
});
}
foreach (WorkflowConditionRuleDto rule in edge.Condition.Rules)
{
if (string.IsNullOrWhiteSpace(rule.PropertyPath))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules.propertyPath",
EdgeId = edge.Id,
Message = "Property condition rules require a property path.",
});
}
if (!WorkflowConditionEvaluator.IsSupportedOperator(rule.Operator))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules.operator",
EdgeId = edge.Id,
Message = $"Property condition operator \"{rule.Operator}\" is not supported.",
});
}
if (string.Equals(rule.Operator, "regex", StringComparison.OrdinalIgnoreCase))
{
try
{
_ = new System.Text.RegularExpressions.Regex(rule.Value);
}
catch (ArgumentException)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules.value",
EdgeId = edge.Id,
Message = $"Regex pattern \"{rule.Value}\" is invalid.",
});
}
}
}
}
}
private static bool HasPathToAnyEnd(
string startNodeId,
WorkflowGraphDto graph,
IEnumerable<string> endNodeIds)
{
HashSet<string> endSet = endNodeIds.ToHashSet(StringComparer.Ordinal);
Dictionary<string, List<string>> outgoing = graph.Edges
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.Select(edge => edge.Target).ToList(),
StringComparer.Ordinal);
Queue<string> queue = new([startNodeId]);
HashSet<string> visited = new(StringComparer.Ordinal);
while (queue.Count > 0)
{
string current = queue.Dequeue();
if (!visited.Add(current))
{
continue;
}
if (endSet.Contains(current))
{
return true;
}
foreach (string target in outgoing.GetValueOrDefault(current, []))
{
queue.Enqueue(target);
}
}
return false;
}
private static bool CanReachNode(
WorkflowGraphDto graph,
string startNodeId,
string targetNodeId,
string? excludedEdgeId = null)
{
if (string.Equals(startNodeId, targetNodeId, StringComparison.Ordinal))
{
return true;
}
Dictionary<string, List<string>> outgoing = graph.Edges
.Where(edge => !string.Equals(edge.Id, excludedEdgeId, StringComparison.Ordinal))
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.Select(edge => edge.Target).ToList(),
StringComparer.Ordinal);
Queue<string> queue = new([startNodeId]);
HashSet<string> visited = new(StringComparer.Ordinal);
while (queue.Count > 0)
{
string current = queue.Dequeue();
if (!visited.Add(current))
{
continue;
}
foreach (string target in outgoing.GetValueOrDefault(current, []))
{
if (string.Equals(target, targetNodeId, StringComparison.Ordinal))
{
return true;
}
queue.Enqueue(target);
}
}
return false;
}
private static bool IsLoopEdge(WorkflowGraphDto graph, WorkflowEdgeDto edge)
=> CanReachNode(graph, edge.Target, edge.Source, edge.Id);
private static HashSet<string> CollectStronglyConnectedNodes(WorkflowGraphDto graph, string nodeId)
{
HashSet<string> connected = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto candidate in graph.Nodes)
{
if (CanReachNode(graph, nodeId, candidate.Id) && CanReachNode(graph, candidate.Id, nodeId))
{
connected.Add(candidate.Id);
}
}
return connected;
}
}
@@ -8,14 +8,14 @@ public sealed class AgentIdentityResolverTests
[Fact]
public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier()
{
PatternDefinitionDto pattern = CreatePattern(
WorkflowDefinitionDto workflow = CreateWorkflow(
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-product", name: "Product"),
CreateAgent("agent-concurrent-architect", "Architect"),
CreateAgent("agent-concurrent-product", "Product"),
]);
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
workflow,
"Architect_agent_concurrent_architect",
out AgentIdentity agent);
@@ -27,14 +27,14 @@ public sealed class AgentIdentityResolverTests
[Fact]
public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId()
{
PatternDefinitionDto pattern = CreatePattern(
WorkflowDefinitionDto workflow = CreateWorkflow(
[
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
CreateAgent("agent-single-primary", "Primary Agent"),
],
mode: "single");
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
workflow,
"Primary_Agent_agent_single_primary",
out AgentIdentity agent);
@@ -46,14 +46,14 @@ public sealed class AgentIdentityResolverTests
[Fact]
public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent()
{
PatternDefinitionDto pattern = CreatePattern(
WorkflowDefinitionDto workflow = CreateWorkflow(
[
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
CreateAgent("agent-single-primary", "Primary Agent"),
],
mode: "single");
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
workflow,
"assistant",
out AgentIdentity agent);
@@ -63,16 +63,16 @@ public sealed class AgentIdentityResolverTests
}
[Fact]
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentPattern()
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentWorkflow()
{
PatternDefinitionDto pattern = CreatePattern(
WorkflowDefinitionDto workflow = CreateWorkflow(
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-product", name: "Product"),
CreateAgent("agent-concurrent-architect", "Architect"),
CreateAgent("agent-concurrent-product", "Product"),
]);
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
pattern,
workflow,
"assistant",
out _);
@@ -82,14 +82,14 @@ public sealed class AgentIdentityResolverTests
[Fact]
public void ResolveDisplayAuthorName_UsesCanonicalAgentName()
{
PatternDefinitionDto pattern = CreatePattern(
WorkflowDefinitionDto workflow = CreateWorkflow(
[
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
CreateAgent("agent-concurrent-implementer", "Implementer"),
],
mode: "single");
orchestrationMode: "single");
string authorName = AgentIdentityResolver.ResolveDisplayAuthorName(
pattern,
workflow,
"Implementer_agent_concurrent_implementer");
Assert.Equal("Implementer", authorName);
@@ -98,14 +98,14 @@ public sealed class AgentIdentityResolverTests
[Fact]
public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant()
{
PatternDefinitionDto pattern = CreatePattern(
WorkflowDefinitionDto workflow = CreateWorkflow(
[
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
CreateAgent("agent-handoff-ux", "UX Specialist"),
CreateAgent("agent-handoff-runtime", "Runtime Specialist"),
]);
bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity(
pattern,
workflow,
"assistant",
new AgentIdentity("agent-handoff-ux", "UX Specialist"),
out AgentIdentity agent);
@@ -115,28 +115,43 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("UX Specialist", agent.AgentName);
}
private static PatternDefinitionDto CreatePattern(
IReadOnlyList<PatternAgentDefinitionDto> agents,
string mode = "concurrent")
private static WorkflowDefinitionDto CreateWorkflow(
IReadOnlyList<WorkflowNodeDto> agents,
string orchestrationMode = "concurrent")
{
return new PatternDefinitionDto
return new WorkflowDefinitionDto
{
Id = $"{mode}-pattern",
Name = "Pattern",
Mode = mode,
Availability = "available",
Agents = agents,
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
.. agents,
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = orchestrationMode,
},
};
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
return new WorkflowNodeDto
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
}
@@ -8,19 +8,10 @@ public sealed class AgentInstructionComposerTests
[Fact]
public void Compose_LeavesNonHandoffInstructionsUnchanged()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-sequential",
Name = "Sequential",
Mode = "sequential",
Availability = "available",
};
PatternAgentDefinitionDto agent = CreateAgent(
id: "agent-reviewer",
name: "Reviewer",
instructions: "Review the proposal.");
WorkflowDefinitionDto workflow = CreateWorkflow("sequential");
WorkflowNodeDto agent = CreateAgent("agent-reviewer", "Reviewer", "Review the proposal.");
string instructions = AgentInstructionComposer.Compose(pattern, agent, agentIndex: 0);
string instructions = AgentInstructionComposer.Compose(workflow, agent, agentIndex: 0);
Assert.Equal("Review the proposal.", instructions);
}
@@ -28,24 +19,12 @@ public sealed class AgentInstructionComposerTests
[Fact]
public void Compose_StrengthensGroupChatCollaborationRoles()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-group-chat",
Name = "Group Chat",
Mode = "group-chat",
Availability = "available",
};
PatternAgentDefinitionDto writer = CreateAgent(
id: "agent-group-writer",
name: "Writer",
instructions: "Draft an answer.");
PatternAgentDefinitionDto reviewer = CreateAgent(
id: "agent-group-reviewer",
name: "Reviewer",
instructions: "Review the draft.");
WorkflowDefinitionDto workflow = CreateWorkflow("group-chat");
WorkflowNodeDto writer = CreateAgent("agent-group-writer", "Writer", "Draft an answer.");
WorkflowNodeDto reviewer = CreateAgent("agent-group-reviewer", "Reviewer", "Review the draft.");
string writerInstructions = AgentInstructionComposer.Compose(pattern, writer, agentIndex: 0);
string reviewerInstructions = AgentInstructionComposer.Compose(pattern, reviewer, agentIndex: 1);
string writerInstructions = AgentInstructionComposer.Compose(workflow, writer, agentIndex: 0);
string reviewerInstructions = AgentInstructionComposer.Compose(workflow, reviewer, agentIndex: 1);
Assert.Contains("collaborative multi-turn group chat", writerInstructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("refine your earlier draft", writerInstructions, StringComparison.OrdinalIgnoreCase);
@@ -54,66 +33,45 @@ public sealed class AgentInstructionComposerTests
}
[Fact]
public void Compose_StrengthensHandoffTriageInstructions()
public void Compose_LeavesHandoffTriagePromptFocusedOnAgentInstructions()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-handoff",
Name = "Handoff",
Mode = "handoff",
Availability = "available",
};
PatternAgentDefinitionDto triage = CreateAgent(
id: "agent-handoff-triage",
name: "Triage",
instructions: "You triage requests and must hand them off to the most appropriate specialist.");
WorkflowDefinitionDto workflow = CreateWorkflow("handoff");
WorkflowNodeDto triage = CreateAgent(
"agent-handoff-triage",
"Triage",
"You triage requests and must hand them off to the most appropriate specialist.");
string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0);
string instructions = AgentInstructionComposer.Compose(workflow, 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()
{
Id = "pattern-handoff",
Name = "Handoff",
Mode = "handoff",
Availability = "available",
};
PatternAgentDefinitionDto specialist = CreateAgent(
id: "agent-handoff-ux",
name: "UX Specialist",
instructions: "You focus on navigation, UX, and interaction details.");
WorkflowDefinitionDto workflow = CreateWorkflow("handoff");
WorkflowNodeDto specialist = CreateAgent(
"agent-handoff-ux",
"UX Specialist",
"You focus on navigation, UX, and interaction details.");
string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1);
string instructions = AgentInstructionComposer.Compose(workflow, 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]
public void Compose_AddsScratchpadGuidanceForProjectlessQaSessions()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-single",
Name = "Single",
Mode = "single",
Availability = "available",
};
PatternAgentDefinitionDto agent = CreateAgent(
id: "agent-primary",
name: "Primary Agent",
instructions: "You are a helpful assistant.");
WorkflowDefinitionDto workflow = CreateWorkflow("single");
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
string instructions = AgentInstructionComposer.Compose(
pattern,
workflow,
agent,
agentIndex: 0,
workspaceKind: "scratchpad");
@@ -127,20 +85,11 @@ public sealed class AgentInstructionComposerTests
[Fact]
public void Compose_AddsPlanModeGuidanceWhenRequested()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-single",
Name = "Single",
Mode = "single",
Availability = "available",
};
PatternAgentDefinitionDto agent = CreateAgent(
id: "agent-primary",
name: "Primary Agent",
instructions: "You are a helpful assistant.");
WorkflowDefinitionDto workflow = CreateWorkflow("single");
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
string instructions = AgentInstructionComposer.Compose(
pattern,
workflow,
agent,
agentIndex: 0,
interactionMode: "plan");
@@ -154,20 +103,11 @@ public sealed class AgentInstructionComposerTests
[Fact]
public void Compose_InsertsProjectInstructionsBetweenBaseAndRuntimeGuidance()
{
PatternDefinitionDto pattern = new()
{
Id = "pattern-single",
Name = "Single",
Mode = "single",
Availability = "available",
};
PatternAgentDefinitionDto agent = CreateAgent(
id: "agent-primary",
name: "Primary Agent",
instructions: "You are a helpful assistant.");
WorkflowDefinitionDto workflow = CreateWorkflow("single");
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
string instructions = AgentInstructionComposer.Compose(
pattern,
workflow,
agent,
agentIndex: 0,
workspaceKind: "scratchpad",
@@ -184,14 +124,69 @@ public sealed class AgentInstructionComposerTests
< instructions.IndexOf("scratchpad mode", StringComparison.OrdinalIgnoreCase));
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
[Fact]
public void Compose_AppendsPromptInvocationAsATaskDirective()
{
return new PatternAgentDefinitionDto
WorkflowDefinitionDto workflow = CreateWorkflow("single");
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
string instructions = AgentInstructionComposer.Compose(
workflow,
agent,
agentIndex: 0,
promptInvocation: new RunTurnPromptInvocationDto
{
Id = "project_customization_prompt_doc_review",
Name = "doc-review",
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
Description = "Review docs for missing steps",
Agent = "plan",
Model = "Claude Sonnet 4.5",
Tools = ["view", "glob"],
ResolvedPrompt = "Review the docs for missing steps and propose updates."
});
Assert.Contains("repository prompt file", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains(@"Source: .github\prompts\docs\doc-review.prompt.md", instructions, StringComparison.Ordinal);
Assert.Contains("Name: doc-review", instructions, StringComparison.Ordinal);
Assert.Contains("Description: Review docs for missing steps", instructions, StringComparison.Ordinal);
Assert.Contains("Agent: plan", instructions, StringComparison.Ordinal);
Assert.Contains("Model: Claude Sonnet 4.5", instructions, StringComparison.Ordinal);
Assert.Contains("Tools: view, glob", instructions, StringComparison.Ordinal);
Assert.Contains(
"Prompt instructions:\nReview the docs for missing steps and propose updates.",
instructions,
StringComparison.Ordinal);
}
private static WorkflowDefinitionDto CreateWorkflow(string orchestrationMode)
{
return new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Settings = new WorkflowSettingsDto
{
OrchestrationMode = orchestrationMode,
},
};
}
private static WorkflowNodeDto CreateAgent(string id, string name, string instructions)
{
return new WorkflowNodeDto
{
Id = id,
Name = name,
Instructions = instructions,
Model = "gpt-5.4",
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Instructions = instructions,
Model = "gpt-5.4",
},
};
}
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
@@ -4,12 +4,31 @@ 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;
public sealed class CopilotAgentBundleTests
{
[Fact]
public void GetAllAgentNodes_IncludesReferencedSubworkflowAgentsInTraversalOrder()
{
WorkflowDefinitionDto childWorkflow = CreateSubworkflowChild(
"child-workflow",
CreateAgentNode("agent-child-1", "Child Agent 1"),
CreateAgentNode("agent-child-2", "Child Agent 2"));
WorkflowDefinitionDto parentWorkflow = CreateSubworkflowParent(
CreateAgentNode("agent-parent", "Parent Agent"),
workflowId: childWorkflow.Id);
IReadOnlyList<WorkflowNodeDto> agentNodes = parentWorkflow.GetAllAgentNodes([childWorkflow]);
Assert.Equal(
["agent-parent", "agent-child-1", "agent-child-2"],
agentNodes.Select(node => node.GetAgentId()).ToArray());
}
[Fact]
public void ApplySessionTooling_MapsMcpServersAndToolsOntoTheSessionConfig()
{
@@ -53,6 +72,34 @@ public sealed class CopilotAgentBundleTests
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
}
[Fact]
public void ApplyPromptInvocation_RestrictsAvailableToolsAndKeepsHandoffTools()
{
SessionConfig sessionConfig = new()
{
AvailableTools = ["view", "glob", "edit"],
Tools = [CreateTool("view"), CreateTool("edit"), CreateTool("handoff_to_reviewer")],
};
CopilotAgentBundle.ApplyPromptInvocation(
sessionConfig,
new RunTurnPromptInvocationDto
{
Id = "project_customization_prompt_doc_review",
Name = "doc-review",
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
ResolvedPrompt = "Review the docs for missing steps.",
Tools = ["view"],
});
Assert.Equal(["view", "ask_user", "report_intent", "task_complete"], sessionConfig.AvailableTools);
AIFunction[] tools = Assert.IsAssignableFrom<IEnumerable<AIFunction>>(sessionConfig.Tools).ToArray();
Assert.Equal(2, tools.Length);
Assert.Contains(tools, tool => tool.Name == "view");
Assert.Contains(tools, tool => tool.Name == "handoff_to_reviewer");
}
[Fact]
public void Constructor_StoresWhetherHooksAreConfigured()
{
@@ -111,6 +158,128 @@ 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);
}
[Fact]
public void CreateHandoffWorkflowBuilder_MapsConfiguredFilteringAndInstructions()
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
entryAgent,
new HandoffModeSettingsDto
{
ToolCallFiltering = "all",
ReturnToPrevious = true,
HandoffInstructions = "Use custom delegation guidance.",
});
FieldInfo filteringField = typeof(HandoffsWorkflowBuilder).GetField(
"_toolCallFilteringBehavior",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
Assert.Equal(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder));
Assert.Equal("Use custom delegation guidance.", builder.HandoffInstructions);
}
[Fact]
public void CreateHandoffWorkflow_RejectsUnknownTriageNode()
{
WorkflowDefinitionDto workflow = CreateWorkflow(
"handoff",
2,
modeSettings: new OrchestrationModeSettingsDto
{
Handoff = new HandoffModeSettingsDto
{
TriageAgentNodeId = "missing-agent",
},
});
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
CopilotAgentBundle.CreateHandoffWorkflow(workflow, CreateAgents(2)));
Assert.Contains("triage agent node", error.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CreateGroupChatWorkflowBuilder_UsesConfiguredRoundsNameAndDescription()
{
WorkflowDefinitionDto workflow = CreateWorkflow(
"group-chat",
2,
modeSettings: new OrchestrationModeSettingsDto
{
GroupChat = new GroupChatModeSettingsDto
{
SelectionStrategy = "round-robin",
MaxRounds = 7,
},
},
name: "Round Robin Collaboration",
description: "Two agents iterate on a shared answer.");
IReadOnlyList<AIAgent> agents = CreateAgents(2);
GroupChatWorkflowBuilder builder = CopilotAgentBundle.CreateGroupChatWorkflowBuilder(workflow, agents);
FieldInfo managerFactoryField = typeof(GroupChatWorkflowBuilder).GetField(
"_managerFactory",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a manager factory field.");
FieldInfo participantsField = typeof(GroupChatWorkflowBuilder).GetField(
"_participants",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a participant field.");
FieldInfo nameField = typeof(GroupChatWorkflowBuilder).GetField(
"_name",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a name field.");
FieldInfo descriptionField = typeof(GroupChatWorkflowBuilder).GetField(
"_description",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a description field.");
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory =
Assert.IsType<Func<IReadOnlyList<AIAgent>, GroupChatManager>>(managerFactoryField.GetValue(builder));
RoundRobinGroupChatManager manager = Assert.IsType<RoundRobinGroupChatManager>(managerFactory(agents));
HashSet<AIAgent> participants = Assert.IsType<HashSet<AIAgent>>(participantsField.GetValue(builder));
Assert.Equal(7, manager.MaximumIterationCount);
Assert.Equal(2, participants.Count);
Assert.Equal("Round Robin Collaboration", Assert.IsType<string>(nameField.GetValue(builder)));
Assert.Equal("Two agents iterate on a shared answer.", Assert.IsType<string>(descriptionField.GetValue(builder)));
}
[Fact]
public void CreateAgentHostOptions_UsesExpectedAryxDefaults()
{
AIAgentHostOptions options = CopilotAgentBundle.CreateAgentHostOptions();
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 +305,7 @@ public sealed class CopilotAgentBundleTests
}
[Fact]
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls()
{
AssistantMessageDataToolRequestsItem[] toolRequests =
{
@@ -148,9 +317,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]
@@ -220,28 +479,12 @@ public sealed class CopilotAgentBundleTests
ProjectPath = @"C:\workspace\project",
WorkspaceKind = "project",
Mode = "interactive",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
],
},
Workflow = CreateWorkflow("single", 1),
};
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
command,
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
agentIndex: 0);
Assert.Null(sessionConfig.SessionId);
@@ -260,33 +503,74 @@ public sealed class CopilotAgentBundleTests
WorkspaceKind = "project",
Mode = "interactive",
ProjectInstructions = "Follow repository guidance.",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
],
},
Workflow = CreateWorkflow("single", 1),
};
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
command,
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
agentIndex: 0);
Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content);
}
[Fact]
public void CreateSessionConfig_UsesPromptAgentOverride()
{
RunTurnCommandDto command = new()
{
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
WorkspaceKind = "project",
Mode = "interactive",
PromptInvocation = new RunTurnPromptInvocationDto
{
Id = "project_customization_prompt_doc_review",
Name = "doc-review",
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
Agent = "designer",
ResolvedPrompt = "Review the docs for missing steps.",
},
Workflow = CreateWorkflow("single", 1),
};
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
command,
command.Workflow.GetAgentNodes()[0],
agentIndex: 0);
Assert.Equal("designer", sessionConfig.Agent);
Assert.Contains("Review the docs for missing steps.", sessionConfig.SystemMessage?.Content, StringComparison.Ordinal);
}
[Fact]
public void CreateSessionConfig_DefaultsPromptToolInvocationsToAgentMode()
{
RunTurnCommandDto command = new()
{
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
WorkspaceKind = "project",
Mode = "interactive",
PromptInvocation = new RunTurnPromptInvocationDto
{
Id = "project_customization_prompt_doc_review",
Name = "doc-review",
SourcePath = @".github\prompts\docs\doc-review.prompt.md",
ResolvedPrompt = "Review the docs for missing steps.",
Tools = ["view"],
},
Workflow = CreateWorkflow("single", 1),
};
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
command,
command.Workflow.GetAgentNodes()[0],
agentIndex: 0);
Assert.Equal("agent", sessionConfig.Agent);
}
[Fact]
public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse()
{
@@ -294,13 +578,10 @@ public sealed class CopilotAgentBundleTests
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
Workflow = CreateWorkflow(
"single",
1,
new ApprovalPolicyDto
{
Rules =
[
@@ -310,21 +591,10 @@ public sealed class CopilotAgentBundleTests
AgentIds = ["agent-1"],
},
],
},
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
],
},
}),
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0]);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0]);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
@@ -345,7 +615,7 @@ public sealed class CopilotAgentBundleTests
Assert.Equal("agent-ux", agentId);
}
private static AIFunction CreateTool()
private static AIFunction CreateTool(string name = "echo")
{
ToolTarget target = new();
MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo))
@@ -356,7 +626,7 @@ public sealed class CopilotAgentBundleTests
target,
new AIFunctionFactoryOptions
{
Name = "echo",
Name = name,
Description = "Echo test tool",
});
}
@@ -369,8 +639,199 @@ 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 WorkflowDefinitionDto CreateWorkflow(
string mode,
int agentCount,
ApprovalPolicyDto? approvalPolicy = null,
OrchestrationModeSettingsDto? modeSettings = null,
string? name = null,
string? description = null)
{
return new WorkflowDefinitionDto
{
Id = $"workflow-{mode}",
Name = name ?? $"Workflow {mode}",
Description = description ?? string.Empty,
Graph = new WorkflowGraphDto
{
Nodes =
[
.. Enumerable.Range(1, agentCount).Select(index => new WorkflowNodeDto
{
Id = $"agent-{index}",
Kind = "agent",
Label = $"Agent {index}",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = $"agent-{index}",
Name = $"Agent {index}",
Description = $"Agent {index} description.",
Instructions = "Help.",
Model = "gpt-5.4",
},
}),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = mode,
ApprovalPolicy = approvalPolicy,
ModeSettings = modeSettings,
},
};
}
private static WorkflowDefinitionDto CreateSubworkflowParent(
WorkflowNodeDto directAgent,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowDefinitionDto
{
Id = "parent-workflow",
Name = "Parent Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
directAgent,
new WorkflowNodeDto
{
Id = "sub-workflow",
Kind = "sub-workflow",
Label = "Nested Workflow",
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "sequential",
},
};
}
private static WorkflowDefinitionDto CreateSubworkflowChild(string id, params WorkflowNodeDto[] agentNodes)
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Child Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
.. agentNodes,
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "sequential",
},
};
}
private static WorkflowNodeDto CreateAgentNode(string id, string name)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Description = $"{name} description.",
Instructions = "Help.",
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();
}
}
}
@@ -14,7 +14,7 @@ public sealed class CopilotExitPlanModeCoordinatorTests
ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest(
command,
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
new ExitPlanModeRequestedEvent
{
Data = new ExitPlanModeRequestedData
@@ -50,23 +50,36 @@ public sealed class CopilotExitPlanModeCoordinatorTests
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
Workflow = new WorkflowDefinitionDto
{
Id = "pattern-1",
Name = "Plan Mode Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
],
Id = "workflow-1",
Name = "Plan Mode Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "agent-1",
Kind = "agent",
Label = "Primary",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
},
};
}
}
@@ -14,7 +14,7 @@ public sealed class CopilotMcpOAuthCoordinatorTests
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
command,
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
new McpOauthRequiredEvent
{
Data = new McpOauthRequiredData
@@ -49,23 +49,36 @@ public sealed class CopilotMcpOAuthCoordinatorTests
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
Workflow = new WorkflowDefinitionDto
{
Id = "pattern-1",
Name = "MCP OAuth Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
],
Id = "workflow-1",
Name = "MCP OAuth Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "agent-1",
Kind = "agent",
Label = "Primary",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
},
};
}
}
@@ -23,7 +23,7 @@ public sealed class CopilotSessionHooksTests
],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -64,7 +64,7 @@ public sealed class CopilotSessionHooksTests
],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -93,7 +93,7 @@ public sealed class CopilotSessionHooksTests
],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -123,7 +123,7 @@ public sealed class CopilotSessionHooksTests
public async Task Create_PreToolUseAutoAllowsInternalOrchestrationTools(string toolName)
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -139,7 +139,7 @@ public sealed class CopilotSessionHooksTests
public async Task Create_PreToolUseKeepsStoreMemoryUnderApprovalPolicy()
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -159,7 +159,7 @@ public sealed class CopilotSessionHooksTests
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
{
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -171,6 +171,40 @@ public sealed class CopilotSessionHooksTests
Assert.Equal("allow", decision?.PermissionDecision);
}
[Fact]
public async Task Create_PreToolUseAutoAllowsWhenMcpServerIsApproved()
{
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
["icm-mcp"],
["mcp_server:icm-mcp"]);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "icm-mcp-get_incident_details_by_id",
},
null!);
Assert.Equal("allow", decision?.PermissionDecision);
}
[Fact]
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
{
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "icm-mcp-get_incident_details_by_id",
},
null!);
Assert.Equal("ask", decision?.PermissionDecision);
}
[Fact]
public async Task Create_RunsConfiguredNonPreToolHooks()
{
@@ -185,7 +219,7 @@ public sealed class CopilotSessionHooksTests
ErrorOccurred = [CreateHookCommand("error-hook")],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
await hooks.OnSessionStart!(
new SessionStartHookInput
@@ -259,7 +293,7 @@ public sealed class CopilotSessionHooksTests
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
{
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -278,34 +312,17 @@ public sealed class CopilotSessionHooksTests
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
Pattern = new PatternDefinitionDto
Workflow = CreateWorkflow(new ApprovalPolicyDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
},
Agents =
Rules =
[
new PatternAgentDefinitionDto
new ApprovalCheckpointRuleDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
},
}),
};
}
@@ -317,15 +334,7 @@ public sealed class CopilotSessionHooksTests
RequestId = command.RequestId,
SessionId = command.SessionId,
ProjectPath = command.ProjectPath,
Pattern = new PatternDefinitionDto
{
Id = command.Pattern.Id,
Name = command.Pattern.Name,
Mode = command.Pattern.Mode,
Availability = command.Pattern.Availability,
ApprovalPolicy = new ApprovalPolicyDto(),
Agents = command.Pattern.Agents,
},
Workflow = CreateWorkflow(new ApprovalPolicyDto()),
};
}
@@ -336,38 +345,84 @@ public sealed class CopilotSessionHooksTests
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
Pattern = new PatternDefinitionDto
Workflow = CreateWorkflow(new ApprovalPolicyDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = [category],
},
Agents =
Rules =
[
new PatternAgentDefinitionDto
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = [category],
}),
};
}
private static RunTurnCommandDto CreateCommandWithConfiguredMcpServers(
IReadOnlyList<string> serverNames,
IReadOnlyList<string>? autoApprovedToolNames = null)
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
return new RunTurnCommandDto
{
RequestId = command.RequestId,
SessionId = command.SessionId,
ProjectPath = command.ProjectPath,
Tooling = new RunTurnToolingConfigDto
{
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
},
Workflow = CreateWorkflow(new ApprovalPolicyDto
{
Rules = command.Workflow.Settings.ApprovalPolicy?.Rules ?? [],
AutoApprovedToolNames = autoApprovedToolNames ?? [],
}),
};
}
private static WorkflowDefinitionDto CreateWorkflow(ApprovalPolicyDto approvalPolicy)
{
return new WorkflowDefinitionDto
{
Id = "workflow-1",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
Kind = "agent",
Label = "Primary",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
ApprovalPolicy = approvalPolicy,
},
};
}
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
=> new()
{
Id = serverName,
Name = serverName,
};
private static HookCommandDefinition CreateHookCommand(string name)
=> new()
{
@@ -406,3 +461,4 @@ public sealed class CopilotSessionHooksTests
string InputJson,
string ProjectPath);
}
@@ -14,7 +14,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
new McpOauthRequiredEvent
{
Data = new McpOauthRequiredData
@@ -37,7 +37,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -60,30 +60,56 @@ public sealed class CopilotTurnExecutionStateTests
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId()
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[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.Workflow.GetAgentNodes()[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 QueueCompletedActivity_QueuesCompletedAgentActivity()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.QueueCompletedActivity(new AgentIdentity("agent-1", "Primary"));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("completed", activity.ActivityType);
Assert.Equal("agent-1", activity.AgentId);
Assert.Equal("Primary", activity.AgentName);
Assert.Equal(command.RequestId, activity.RequestId);
Assert.Equal(command.SessionId, activity.SessionId);
}
[Fact]
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
{
@@ -91,7 +117,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -132,7 +158,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -148,7 +174,7 @@ public sealed class CopilotTurnExecutionStateTests
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -162,7 +188,7 @@ public sealed class CopilotTurnExecutionStateTests
}
"""));
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -178,6 +204,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));
@@ -193,7 +223,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -217,7 +247,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -253,7 +283,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -284,7 +314,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -342,7 +372,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -372,7 +402,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -403,7 +433,7 @@ public sealed class CopilotTurnExecutionStateTests
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
Assert.Equal("start", evt.Phase);
@@ -418,7 +448,7 @@ public sealed class CopilotTurnExecutionStateTests
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
Assert.Equal("end", evt.Phase);
@@ -436,8 +466,8 @@ public sealed class CopilotTurnExecutionStateTests
SuppressHookLifecycleEvents = true,
};
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
Assert.Empty(state.DrainPendingEvents());
}
@@ -449,7 +479,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -512,7 +542,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -547,7 +577,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -610,7 +640,7 @@ public sealed class CopilotTurnExecutionStateTests
// Simulate assistant message with tool requests → triggers reclassification
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -658,23 +688,36 @@ public sealed class CopilotTurnExecutionStateTests
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
Workflow = new WorkflowDefinitionDto
{
Id = "pattern-1",
Name = "MCP OAuth Pattern",
Mode = "single",
Availability = "available",
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
],
Id = "workflow-1",
Name = "Execution State Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "agent-1",
Kind = "agent",
Label = "Primary",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
},
};
}
}
@@ -15,7 +15,7 @@ public sealed class CopilotUserInputCoordinatorTests
Task<UserInputResponse> pending = coordinator.RequestUserInputAsync(
command,
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
new UserInputRequest
{
Question = "How should I proceed?",
@@ -76,33 +76,40 @@ public sealed class CopilotUserInputCoordinatorTests
Assert.Contains("is not pending", error.Message);
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
};
}
private static RunTurnCommandDto CreateUserInputCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
Workflow = new WorkflowDefinitionDto
{
Id = "pattern-1",
Name = "User Input Pattern",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent("agent-1", "Primary"),
],
Id = "workflow-1",
Name = "User Input Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "agent-1",
Kind = "agent",
Label = "Primary",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
},
};
}
File diff suppressed because it is too large Load Diff
@@ -11,21 +11,32 @@ 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);
}
[Fact]
public void CreateForwardReason_UsesTargetSpecialtyAndOwnership()
{
PatternAgentDefinitionDto specialist = new()
WorkflowNodeDto specialist = new()
{
Id = "agent-handoff-ux",
Name = "UX Specialist",
Description = "Handles user experience questions.",
Instructions = "Focus on UX.",
Model = "claude-opus-4.5",
Kind = "agent",
Label = "UX Specialist",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-handoff-ux",
Name = "UX Specialist",
Description = "Handles user experience questions.",
Instructions = "Focus on UX.",
Model = "claude-opus-4.5",
},
};
string reason = HandoffWorkflowGuidance.CreateForwardReason(specialist);
@@ -38,13 +49,20 @@ public sealed class HandoffWorkflowGuidanceTests
[Fact]
public void CreateReturnReason_RestrictsReturnToReroutingCases()
{
PatternAgentDefinitionDto triage = new()
WorkflowNodeDto triage = new()
{
Id = "agent-handoff-triage",
Name = "Triage",
Description = "Routes the request to the right specialist.",
Instructions = "Triages requests.",
Model = "gpt-5.4",
Kind = "agent",
Label = "Triage",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-handoff-triage",
Name = "Triage",
Description = "Routes the request to the right specialist.",
Instructions = "Triages requests.",
Model = "gpt-5.4",
},
};
string reason = HandoffWorkflowGuidance.CreateReturnReason(triage);
@@ -1,127 +0,0 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class PatternGraphResolverTests
{
[Fact]
public void ResolveOrderedAgentIds_UsesSequentialGraphPath()
{
PatternDefinitionDto pattern = CreatePattern(
"sequential",
[
CreateAgent("agent-1", "Analyst"),
CreateAgent("agent-2", "Builder"),
CreateAgent("agent-3", "Reviewer"),
],
new PatternGraphDto
{
Nodes =
[
CreateSystemNode("system-user-input", "user-input"),
CreateAgentNode("agent-1", 0),
CreateAgentNode("agent-2", 1),
CreateAgentNode("agent-3", 2),
CreateSystemNode("system-user-output", "user-output"),
],
Edges =
[
CreateEdge("system-user-input", "agent-node-agent-3"),
CreateEdge("agent-node-agent-3", "agent-node-agent-1"),
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
CreateEdge("agent-node-agent-2", "system-user-output"),
],
});
IReadOnlyList<string> orderedAgentIds = PatternGraphResolver.ResolveOrderedAgentIds(pattern);
Assert.Equal(["agent-3", "agent-1", "agent-2"], orderedAgentIds);
}
[Fact]
public void ResolveHandoff_UsesExplicitEntryAndRoutes()
{
PatternDefinitionDto pattern = CreatePattern(
"handoff",
[
CreateAgent("agent-1", "Triage"),
CreateAgent("agent-2", "UX"),
CreateAgent("agent-3", "Runtime"),
],
new PatternGraphDto
{
Nodes =
[
CreateSystemNode("system-user-input", "user-input"),
CreateSystemNode("system-user-output", "user-output"),
CreateAgentNode("agent-1", 0),
CreateAgentNode("agent-2", 1),
CreateAgentNode("agent-3", 2),
],
Edges =
[
CreateEdge("system-user-input", "agent-node-agent-3"),
CreateEdge("agent-node-agent-3", "agent-node-agent-2"),
CreateEdge("agent-node-agent-2", "agent-node-agent-1"),
CreateEdge("agent-node-agent-2", "system-user-output"),
],
});
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
Assert.Equal("agent-3", topology.EntryAgentId);
Assert.Contains(new PatternHandoffRoute("agent-3", "agent-2"), topology.Routes);
Assert.Contains(new PatternHandoffRoute("agent-2", "agent-1"), topology.Routes);
Assert.DoesNotContain(new PatternHandoffRoute("agent-1", "agent-2"), topology.Routes);
}
private static PatternDefinitionDto CreatePattern(
string mode,
IReadOnlyList<PatternAgentDefinitionDto> agents,
PatternGraphDto graph)
=> new()
{
Id = $"{mode}-pattern",
Name = "Pattern",
Mode = mode,
Availability = "available",
Agents = agents,
Graph = graph,
};
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
=> new()
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the user's request.",
};
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
=> new()
{
Id = id,
Kind = kind,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
=> new()
{
Id = $"agent-node-{agentId}",
Kind = "agent",
AgentId = agentId,
Order = order,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphEdgeDto CreateEdge(string source, string target)
=> new()
{
Id = $"edge-{source}-to-{target}",
Source = source,
Target = target,
};
}
@@ -63,23 +63,50 @@ public sealed class SidecarProtocolHostTests
}
[Fact]
public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion()
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
{
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidatePatternCommandDto
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidateWorkflowCommandDto
{
Type = "validate-pattern",
RequestId = "validate-1",
Pattern = new PatternDefinitionDto
Type = "validate-workflow",
RequestId = "validate-workflow-1",
Workflow = new WorkflowDefinitionDto
{
Id = "single-pattern",
Id = "workflow-1",
Name = "",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(),
CreateAgent(id: "agent-2", name: "Reviewer", model: ""),
],
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-end",
Source = "start",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
},
});
@@ -87,24 +114,21 @@ public sealed class SidecarProtocolHostTests
events,
validationEvent =>
{
Assert.Equal("pattern-validation", validationEvent.GetProperty("type").GetString());
Assert.Equal("validate-1", validationEvent.GetProperty("requestId").GetString());
Assert.Equal("workflow-validation", validationEvent.GetProperty("type").GetString());
Assert.Equal("validate-workflow-1", validationEvent.GetProperty("requestId").GetString());
JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray();
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "name"
&& issue.GetProperty("message").GetString() == "Pattern name is required.");
&& issue.GetProperty("message").GetString() == "Workflow name is required.");
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "agents"
&& issue.GetProperty("message").GetString() == "Single-agent chat requires exactly one agent.");
Assert.Contains(issues, issue =>
issue.GetProperty("field").GetString() == "agents.model"
&& issue.GetProperty("message").GetString() == "Agent \"Reviewer\" requires a model identifier.");
issue.GetProperty("field").GetString() == "graph.nodes"
&& issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one executable work node.");
},
completionEvent =>
{
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
Assert.Equal("validate-1", completionEvent.GetProperty("requestId").GetString());
Assert.Equal("validate-workflow-1", completionEvent.GetProperty("requestId").GetString());
});
}
@@ -112,7 +136,7 @@ public sealed class SidecarProtocolHostTests
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onActivity(new AgentActivityEventDto
@@ -160,37 +184,7 @@ public sealed class SidecarProtocolHostTests
];
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
new RunTurnCommandDto
{
Type = "run-turn",
RequestId = "turn-1",
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 =
[
new ChatMessageDto
{
Id = "user-1",
Role = "user",
AuthorName = "You",
Content = "Hello",
CreatedAt = "2026-01-01T00:00:00.0000000Z",
},
],
},
host);
IReadOnlyList<JsonElement> events = await RunHostAsync(CreateRunTurnCommand(), host);
Assert.Collection(
events,
@@ -232,12 +226,65 @@ public sealed class SidecarProtocolHostTests
});
}
[Fact]
public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion()
{
SidecarProtocolHost host = new(
new WorkflowValidator(),
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(
CreateRunTurnCommand(requestId: "turn-diagnostic", 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()
{
string? capturedMode = null;
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
capturedMode = command.Mode;
@@ -245,25 +292,7 @@ public sealed class SidecarProtocolHostTests
}));
await RunHostAsync(
new RunTurnCommandDto
{
Type = "run-turn",
RequestId = "turn-plan",
SessionId = "session-1",
ProjectPath = "C:\\workspace\\project",
Mode = "plan",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(name: "Primary"),
],
},
},
CreateRunTurnCommand(requestId: "turn-plan", interactionMode: "plan"),
host);
Assert.Equal("plan", capturedMode);
@@ -273,7 +302,7 @@ public sealed class SidecarProtocolHostTests
public async Task RunTurnCommand_ReturnsApprovalEvents()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onApproval(new ApprovalRequestedEventDto
@@ -300,24 +329,7 @@ public sealed class SidecarProtocolHostTests
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
new RunTurnCommandDto
{
Type = "run-turn",
RequestId = "turn-approval",
SessionId = "session-1",
ProjectPath = "C:\\workspace\\project",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(name: "Primary"),
],
},
},
CreateRunTurnCommand(requestId: "turn-approval"),
host);
Assert.Collection(
@@ -351,7 +363,7 @@ public sealed class SidecarProtocolHostTests
public async Task RunTurnCommand_ReturnsUserInputEvents()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onUserInput(new UserInputRequestedEventDto
@@ -371,24 +383,7 @@ public sealed class SidecarProtocolHostTests
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
new RunTurnCommandDto
{
Type = "run-turn",
RequestId = "turn-user-input",
SessionId = "session-1",
ProjectPath = "C:\\workspace\\project",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(name: "Primary"),
],
},
},
CreateRunTurnCommand(requestId: "turn-user-input"),
host);
Assert.Collection(
@@ -424,7 +419,7 @@ public sealed class SidecarProtocolHostTests
public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onMcpOAuthRequired(new McpOauthRequiredEventDto
@@ -482,7 +477,7 @@ public sealed class SidecarProtocolHostTests
public async Task RunTurnCommand_ReturnsExitPlanModeEvents()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onExitPlanMode(new ExitPlanModeRequestedEventDto
@@ -503,25 +498,7 @@ public sealed class SidecarProtocolHostTests
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
new RunTurnCommandDto
{
Type = "run-turn",
RequestId = "turn-plan-mode",
SessionId = "session-1",
ProjectPath = "C:\\workspace\\project",
Mode = "plan",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(name: "Primary"),
],
},
},
CreateRunTurnCommand(requestId: "turn-plan-mode", interactionMode: "plan"),
host);
Assert.Collection(
@@ -557,7 +534,7 @@ public sealed class SidecarProtocolHostTests
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await Task.Delay(Timeout.Infinite, cancellationToken);
@@ -605,7 +582,7 @@ public sealed class SidecarProtocolHostTests
public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => []));
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
@@ -627,7 +604,7 @@ public sealed class SidecarProtocolHostTests
{
ResolveApprovalCommandDto? captured = null;
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
resolveApprovalHandler: (command, cancellationToken) =>
@@ -660,7 +637,7 @@ public sealed class SidecarProtocolHostTests
{
ResolveUserInputCommandDto? captured = null;
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
resolveUserInputHandler: (command, cancellationToken) =>
@@ -784,7 +761,7 @@ public sealed class SidecarProtocolHostTests
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
sessionManager: new FakeSessionManager
{
Sessions =
@@ -831,7 +808,7 @@ public sealed class SidecarProtocolHostTests
],
};
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
sessionManager: sessionManager);
IReadOnlyList<JsonElement> events = await RunHostAsync(
@@ -854,7 +831,7 @@ public sealed class SidecarProtocolHostTests
public async Task GetQuotaCommand_ReturnsQuotaResultEvent()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
sessionManager: new FakeSessionManager
{
QuotaSnapshots = new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal)
@@ -896,7 +873,7 @@ public sealed class SidecarProtocolHostTests
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return [];
});
SidecarProtocolHost host = new(new PatternValidator(), runner);
SidecarProtocolHost host = new(new WorkflowValidator(), runner);
IReadOnlyList<JsonElement> events = await RunHostAsync(
[
@@ -957,7 +934,7 @@ public sealed class SidecarProtocolHostTests
private static SidecarProtocolHost CreateHostForTests()
{
return new SidecarProtocolHost(
new PatternValidator(),
new WorkflowValidator(),
capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto
{
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
@@ -1035,24 +1012,35 @@ public sealed class SidecarProtocolHostTests
return events;
}
private static PatternAgentDefinitionDto CreateAgent(
private static WorkflowNodeDto CreateAgent(
string id = "agent-1",
string name = "Primary",
string model = "gpt-5.4",
string instructions = "Help with the user's request.")
{
return new PatternAgentDefinitionDto
return new WorkflowNodeDto
{
Id = id,
Name = name,
Model = model,
Instructions = instructions,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = model,
Instructions = instructions,
},
};
}
private static RunTurnCommandDto CreateRunTurnCommand(
string requestId = "turn-1",
string sessionId = "session-1")
string sessionId = "session-1",
string mode = "single",
string interactionMode = "interactive",
IReadOnlyList<WorkflowNodeDto>? agents = null,
IReadOnlyList<ChatMessageDto>? messages = null)
{
return new RunTurnCommandDto
{
@@ -1060,18 +1048,9 @@ public sealed class SidecarProtocolHostTests
RequestId = requestId,
SessionId = sessionId,
ProjectPath = "C:\\workspace\\project",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent(name: "Primary"),
],
},
Messages =
Mode = interactionMode,
Workflow = CreateWorkflow(mode, agents),
Messages = messages ??
[
new ChatMessageDto
{
@@ -1085,6 +1064,25 @@ public sealed class SidecarProtocolHostTests
};
}
private static WorkflowDefinitionDto CreateWorkflow(
string mode = "single",
IReadOnlyList<WorkflowNodeDto>? agents = null)
{
return new WorkflowDefinitionDto
{
Id = $"workflow-{mode}",
Name = "Single Agent",
Graph = new WorkflowGraphDto
{
Nodes = [.. agents ?? [CreateAgent(name: "Primary")]],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = mode,
},
};
}
private sealed class FakeWorkflowRunner : ITurnWorkflowRunner
{
private readonly Func<
@@ -1184,3 +1182,4 @@ public sealed class SidecarProtocolHostTests
}
}
}
@@ -1,169 +0,0 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class PatternValidatorTests
{
private readonly PatternValidator _validator = new();
[Fact]
public void SingleAgentPattern_WithExactlyOneAgent_IsValid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"single",
[CreateAgent()]));
Assert.Empty(issues);
}
[Fact]
public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"handoff",
[CreateAgent()]));
Assert.Contains(issues, issue =>
issue.Field == "agents"
&& issue.Message == "Handoff orchestration requires at least two agents.");
}
[Fact]
public void AgentWithoutModel_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"sequential",
[
CreateAgent(model: ""),
CreateAgent(id: "agent-2", name: "Reviewer"),
]));
Assert.Contains(issues, issue =>
issue.Field == "agents.model"
&& issue.Message == "Agent \"Primary\" requires a model identifier.");
}
[Fact]
public void MagenticPattern_IsReportedAsUnavailable()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"magentic",
[
CreateAgent(id: "agent-1", name: "Planner", instructions: "Plan the task."),
CreateAgent(
id: "agent-2",
name: "Specialist",
model: "claude-opus-4.5",
instructions: "Complete the task."),
],
availability: "unavailable",
unavailabilityReason: "Unsupported in C#.",
name: "Magentic"));
Assert.Contains(issues, issue =>
issue.Field == "availability"
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
Assert.Contains(issues, issue =>
issue.Field == "mode"
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void SequentialPattern_WithBranchedGraph_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"sequential",
[
CreateAgent(id: "agent-1", name: "Analyst"),
CreateAgent(id: "agent-2", name: "Builder"),
],
graph: new PatternGraphDto
{
Nodes =
[
CreateSystemNode("system-user-input", "user-input"),
CreateAgentNode("agent-1", 0),
CreateAgentNode("agent-2", 1),
CreateSystemNode("system-user-output", "user-output"),
],
Edges =
[
CreateEdge("system-user-input", "agent-node-agent-1"),
CreateEdge("system-user-input", "agent-node-agent-2"),
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
CreateEdge("agent-node-agent-2", "system-user-output"),
],
}));
Assert.Contains(issues, issue =>
issue.Field == "graph"
&& issue.Message.Contains("single path", StringComparison.OrdinalIgnoreCase));
}
private static PatternDefinitionDto CreatePattern(
string mode,
IReadOnlyList<PatternAgentDefinitionDto> agents,
string availability = "available",
string? unavailabilityReason = null,
string name = "Pattern",
PatternGraphDto? graph = null)
{
return new PatternDefinitionDto
{
Id = $"{mode}-pattern",
Name = name,
Mode = mode,
Availability = availability,
UnavailabilityReason = unavailabilityReason,
Agents = agents,
Graph = graph,
};
}
private static PatternAgentDefinitionDto CreateAgent(
string id = "agent-1",
string name = "Primary",
string model = "gpt-5.4",
string instructions = "Help with the user's request.")
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = model,
Instructions = instructions,
};
}
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
=> new()
{
Id = id,
Kind = kind,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
=> new()
{
Id = $"agent-node-{agentId}",
Kind = "agent",
AgentId = agentId,
Order = order,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphEdgeDto CreateEdge(string source, string target)
=> new()
{
Id = $"edge-{source}-to-{target}",
Source = source,
Target = target,
};
}
@@ -0,0 +1,80 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class WorkflowConditionEvaluatorTests
{
[Fact]
public void Evaluate_PropertyCondition_MatchesPayload()
{
EdgeConditionDto condition = new()
{
Type = "property",
Combinator = "and",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Role",
Operator = "equals",
Value = "user",
},
],
};
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 1, "hello"));
Assert.True(matched);
}
[Fact]
public void Evaluate_ExpressionCondition_MatchesPayload()
{
EdgeConditionDto condition = new()
{
Type = "expression",
Expression = "Iteration < 3 && Role == \"user\"",
};
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 2, "hello"));
Assert.True(matched);
}
[Fact]
public void Compile_LoopCondition_StopsAfterMaxIterations()
{
WorkflowEdgeDto edge = new()
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
IsLoop = true,
MaxIterations = 2,
Condition = new EdgeConditionDto
{
Type = "property",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Iteration",
Operator = "lt",
Value = "10",
},
],
},
};
Func<object?, bool>? compiled = WorkflowConditionEvaluator.Compile(edge);
Assert.NotNull(compiled);
Assert.True(compiled!(new TestPayload("user", 1, "hello")));
Assert.True(compiled(new TestPayload("user", 2, "hello")));
Assert.False(compiled(new TestPayload("user", 3, "hello")));
}
private sealed record TestPayload(string Role, int Iteration, string Content);
}
@@ -1,3 +1,4 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using Aryx.AgentHost.Contracts;
@@ -15,7 +16,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
["viewRange"] = new object[] { 10, 25 },
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
@@ -28,6 +33,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-1", activity.AgentId);
Assert.Equal("Primary", activity.AgentName);
Assert.Equal("view", activity.ToolName);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
Assert.Equal("view", toolNamesByCallId["call-1"]);
}
@@ -36,7 +44,15 @@ public sealed class WorkflowRequestInfoInterpreterTests
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateMcpToolCall("call-1", "git.status", "Git MCP"));
CreateMcpToolCall(
"call-1",
"git.status",
"Git MCP",
new Dictionary<string, object?>
{
["path"] = @"C:\workspace",
["includeIgnored"] = true,
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
@@ -47,6 +63,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("git.status", activity.ToolName);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
}
@@ -54,7 +73,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateCodeInterpreterToolCall("call-1"));
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
@@ -65,6 +85,10 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("code interpreter", activity.ToolName);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(
["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
}
@@ -83,9 +107,75 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments);
Assert.Empty(toolNamesByCallId);
}
[Fact]
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["empty"] = " ",
["missing"] = null,
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.Null(activity.ToolArguments);
}
[Fact]
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent(
"call-1",
"powershell",
new Dictionary<string, object?>
{
["command"] = new string('x', 4001),
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
}
[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()
{
@@ -166,54 +256,52 @@ public sealed class WorkflowRequestInfoInterpreterTests
}
private static RunTurnCommandDto CreateSingleAgentCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-single",
Name = "Single Agent",
Mode = "single",
Availability = "available",
Agents =
[
CreateAgent("agent-1", "Primary"),
],
},
};
}
=> CreateCommand("single", [CreateAgent("agent-1", "Primary")]);
private static RunTurnCommandDto CreateHandoffCommand()
=> CreateCommand("handoff",
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateAgent("agent-handoff-ux", "UX Specialist"),
]);
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
Workflow = new WorkflowDefinitionDto
{
Id = "pattern-handoff",
Name = "Handoff Flow",
Mode = "handoff",
Availability = "available",
Agents =
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateAgent("agent-handoff-ux", "UX Specialist"),
],
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. agents],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = orchestrationMode,
},
},
};
}
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new PatternAgentDefinitionDto
return new WorkflowNodeDto
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
@@ -224,22 +312,50 @@ public sealed class WorkflowRequestInfoInterpreterTests
return new RequestInfoEvent(request);
}
private static object CreateCodeInterpreterToolCall(string callId)
private static object CreateCodeInterpreterToolCall(string callId, params string[] inputs)
{
Type type = Type.GetType(
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
object instance = Activator.CreateInstance(type)!;
type.GetProperty("CallId")!.SetValue(instance, callId);
if (inputs.Length > 0)
{
Type aiContentType = Type.GetType(
"Microsoft.Extensions.AI.AIContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
Type textContentType = Type.GetType(
"Microsoft.Extensions.AI.TextContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
IList values = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(aiContentType))!;
foreach (string input in inputs)
{
object textContent = Activator.CreateInstance(textContentType, input)!;
values.Add(textContent);
}
type.GetProperty("Inputs")!.SetValue(instance, values);
}
return instance;
}
private static object CreateMcpToolCall(string callId, string toolName, string serverName)
private static object CreateMcpToolCall(
string callId,
string toolName,
string serverName,
IReadOnlyDictionary<string, object?>? arguments = null)
{
Type type = Type.GetType(
"Microsoft.Extensions.AI.McpServerToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
return Activator.CreateInstance(type, callId, toolName, serverName)!;
object instance = Activator.CreateInstance(type, callId, toolName, serverName)!;
if (arguments is not null)
{
type.GetProperty("Arguments")!.SetValue(instance, arguments);
}
return instance;
}
private static object CreateImageGenerationToolCall()
@@ -0,0 +1,518 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using System.Text.Json;
namespace Aryx.AgentHost.Tests;
public sealed class WorkflowRunnerTests
{
[Fact]
public async Task BuildWorkflow_AcceptsInlineSubworkflows()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateSubworkflowParent(inlineWorkflow: CreateAgentWorkflow("child-inline", "agent-child")),
[CreateChatClientAgent("agent-child", "Child Agent")]);
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
}
[Fact]
public async Task BuildWorkflow_AcceptsReferencedSubworkflowsFromWorkflowLibrary()
{
WorkflowRunner runner = new();
WorkflowDefinitionDto childWorkflow = CreateAgentWorkflow("child-ref", "agent-child");
Workflow workflow = runner.BuildWorkflow(
CreateSubworkflowParent(workflowId: childWorkflow.Id),
[CreateChatClientAgent("agent-child", "Child Agent")],
[childWorkflow]);
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
}
[Fact]
public void BuildWorkflow_RejectsUnknownReferencedSubworkflows()
{
WorkflowRunner runner = new();
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
CreateSubworkflowParent(workflowId: "missing-child"),
[CreateChatClientAgent("agent-child", "Child Agent")],
[]));
Assert.Contains("unknown workflow", error.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task BuildWorkflow_RunsCodeExecutorAndSurfacesOutput()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateSingleNodeWorkflow(
"code-executor",
new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = "return-text:done",
}),
[]);
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
ChatMessage message = Assert.Single(output);
Assert.Equal("done", message.Text);
Assert.Equal("Workflow", message.AuthorName);
}
[Fact]
public async Task BuildWorkflow_FunctionExecutorsUseStateScopes()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateStatefulFunctionWorkflow(),
[]);
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
ChatMessage message = Assert.Single(output);
Assert.Equal("{\"status\":\"complete\"}", message.Text);
}
[Fact]
public async Task BuildWorkflow_RequestPortsRaiseRequestsAndForwardResponses()
{
WorkflowRunner runner = new();
Workflow workflow = runner.BuildWorkflow(
CreateSingleNodeWorkflow(
"request-port",
new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = "approval",
RequestType = "Question",
ResponseType = "string",
Prompt = "Approve the workflow?",
}),
[]);
ChatMessage[] input =
[
new(ChatRole.User, "Please continue."),
];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is RequestInfoEvent requestInfo)
{
Assert.Equal("approval", requestInfo.Request.PortInfo.PortId);
WorkflowRequestPortPromptRequest payload = Assert.IsType<WorkflowRequestPortPromptRequest>(
requestInfo.Request.Data.As<object>());
Assert.Equal("Approve the workflow?", payload.Prompt);
Assert.Equal("request-port", payload.NodeId);
await run.SendResponseAsync(requestInfo.Request.CreateResponse("approved"));
continue;
}
if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> output = Assert.IsType<List<ChatMessage>>(outputEvent.Data);
ChatMessage message = Assert.Single(output);
Assert.Equal("approved", message.Text);
return;
}
}
Assert.Fail("Workflow never produced an output after the request port response.");
}
[Fact]
public void BuildWorkflow_RejectsUnknownFunctionRefsAtBuildTime()
{
WorkflowRunner runner = new();
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
CreateSingleNodeWorkflow(
"function-executor",
new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "missing-function",
}),
[]));
Assert.Contains("unsupported functionRef", error.Message, StringComparison.OrdinalIgnoreCase);
}
private static WorkflowDefinitionDto CreateAgentWorkflow(string id, string agentId)
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Child Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = agentId,
Kind = "agent",
Label = "Child Agent",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = agentId,
Name = "Child Agent",
Model = "gpt-5.4",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-agent",
Source = "start",
Target = agentId,
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-agent-end",
Source = agentId,
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
private static WorkflowDefinitionDto CreateSubworkflowParent(
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowDefinitionDto
{
Id = "parent-workflow",
Name = "Parent Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "sub-workflow",
Kind = "sub-workflow",
Label = "Nested Workflow",
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-sub",
Source = "start",
Target = "sub-workflow",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-sub-end",
Source = "sub-workflow",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
private static WorkflowDefinitionDto CreateSingleNodeWorkflow(string nodeKind, WorkflowNodeConfigDto config)
{
return new WorkflowDefinitionDto
{
Id = $"workflow-{nodeKind}",
Name = $"{nodeKind} Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = nodeKind,
Kind = nodeKind,
Label = nodeKind,
Config = config,
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-node",
Source = "start",
Target = nodeKind,
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-node-end",
Source = nodeKind,
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
private static WorkflowDefinitionDto CreateStatefulFunctionWorkflow()
{
return new WorkflowDefinitionDto
{
Id = "workflow-stateful-function",
Name = "Stateful Function Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "state-get",
Kind = "function-executor",
Label = "Get State",
Config = new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "state:get",
Parameters = new Dictionary<string, JsonElement>
{
["scope"] = JsonDocument.Parse("\"workflow\"").RootElement.Clone(),
["key"] = JsonDocument.Parse("\"status\"").RootElement.Clone(),
},
},
},
new WorkflowNodeDto
{
Id = "state-set",
Kind = "function-executor",
Label = "Set State",
Config = new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "state:set",
Parameters = new Dictionary<string, JsonElement>
{
["scope"] = JsonDocument.Parse("\"workflow\"").RootElement.Clone(),
["key"] = JsonDocument.Parse("\"status\"").RootElement.Clone(),
["value"] = JsonDocument.Parse("{\"status\":\"complete\"}").RootElement.Clone(),
},
},
},
new WorkflowNodeDto
{
Id = "state-read-back",
Kind = "code-executor",
Label = "Read Back",
Config = new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = "state:get:workflow:status",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-get",
Source = "start",
Target = "state-get",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-get-set",
Source = "state-get",
Target = "state-set",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-set-read",
Source = "state-set",
Target = "state-read-back",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-read-end",
Source = "state-read-back",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
StateScopes =
[
new WorkflowStateScopeDto
{
Name = "workflow",
InitialValues = new Dictionary<string, JsonElement>
{
["status"] = JsonDocument.Parse("\"pending\"").RootElement.Clone(),
},
},
],
},
};
}
private static async Task<List<ChatMessage>> RunWorkflowToOutputAsync(Workflow workflow)
{
ChatMessage[] input =
[
new(ChatRole.User, "Run the workflow."),
];
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is WorkflowOutputEvent outputEvent)
{
return Assert.IsType<List<ChatMessage>>(outputEvent.Data);
}
}
Assert.Fail("Workflow did not produce an output.");
return [];
}
private static ChatClientAgent CreateChatClientAgent(string id, string name)
{
return new ChatClientAgent(
new StubChatClient(),
id,
name,
"Stub agent for workflow runner tests.",
[],
null!,
null!);
}
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();
}
}
}
@@ -0,0 +1,443 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class WorkflowValidatorTests
{
private readonly WorkflowValidator _validator = new();
[Fact]
public void Validate_AcceptsInlineSubworkflowNodes()
{
WorkflowDefinitionDto workflow = CreateSubworkflowParent(inlineWorkflow: CreateWorkflow(id: "child"));
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.DoesNotContain(issues, issue => issue.Level == "error");
}
[Fact]
public void Validate_RejectsSubworkflowNodesWithoutSingleSource()
{
WorkflowDefinitionDto workflow = CreateSubworkflowParent();
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.Contains(issues, issue => issue.Field == "graph.nodes.config");
}
[Fact]
public void Validate_RejectsUnknownReferencedWorkflowIdsWhenLibraryProvided()
{
WorkflowDefinitionDto workflow = CreateSubworkflowParent(workflowId: "missing-child");
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow, []);
Assert.Contains(issues, issue => issue.Field == "graph.nodes.config.workflowId");
}
[Fact]
public void Validate_RejectsInvalidConditionOperator()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-agent",
Source = "start",
Target = "agent",
Kind = "direct",
Condition = new EdgeConditionDto
{
Type = "property",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Role",
Operator = "bad-op",
Value = "user",
},
],
},
},
workflow.Graph.Edges[1],
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.Contains(issues, issue => issue.Field == "graph.edges.condition.rules.operator");
}
[Fact]
public void Validate_RejectsLoopWithoutMetadata()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
.. workflow.Graph.Edges,
new WorkflowEdgeDto
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
},
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.Contains(issues, issue => issue.Field == "graph.edges.isLoop");
Assert.Contains(issues, issue => issue.Field == "graph.edges.condition");
Assert.Contains(issues, issue => issue.Field == "graph.edges.maxIterations");
}
[Fact]
public void Validate_AcceptsLoopWithExitPathConditionAndCap()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
.. workflow.Graph.Edges,
new WorkflowEdgeDto
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
IsLoop = true,
MaxIterations = 3,
Condition = new EdgeConditionDto
{
Type = "expression",
Expression = "Iteration < 3",
},
},
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.DoesNotContain(issues, issue => issue.Level == "error");
}
[Fact]
public void Validate_AcceptsLoopWithAlwaysConditionAndMaxIterations()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
.. workflow.Graph.Edges,
new WorkflowEdgeDto
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
IsLoop = true,
MaxIterations = 5,
Condition = new EdgeConditionDto
{
Type = "always",
},
},
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.DoesNotContain(issues, issue => issue.Level == "error");
}
[Fact]
public void Validate_AcceptsPhase4ExecutableNodeKinds()
{
WorkflowDefinitionDto codeWorkflow = CreateSingleNodeWorkflow(
"code-executor",
new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = "return-text:done",
});
WorkflowDefinitionDto functionWorkflow = CreateSingleNodeWorkflow(
"function-executor",
new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = "identity",
});
WorkflowDefinitionDto requestPortWorkflow = CreateSingleNodeWorkflow(
"request-port",
new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = "approval",
RequestType = "Question",
ResponseType = "string",
});
Assert.DoesNotContain(_validator.Validate(codeWorkflow), issue => issue.Level == "error");
Assert.DoesNotContain(_validator.Validate(functionWorkflow), issue => issue.Level == "error");
Assert.DoesNotContain(_validator.Validate(requestPortWorkflow), issue => issue.Level == "error");
}
[Fact]
public void Validate_RejectsInvalidPhase4ExecutorConfigs()
{
WorkflowDefinitionDto codeWorkflow = CreateSingleNodeWorkflow(
"code-executor",
new WorkflowNodeConfigDto
{
Kind = "code-executor",
Implementation = " ",
});
WorkflowDefinitionDto functionWorkflow = CreateSingleNodeWorkflow(
"function-executor",
new WorkflowNodeConfigDto
{
Kind = "function-executor",
FunctionRef = string.Empty,
});
WorkflowDefinitionDto requestPortWorkflow = CreateSingleNodeWorkflow(
"request-port",
new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = " ",
RequestType = "",
ResponseType = null,
});
Assert.Contains(_validator.Validate(codeWorkflow), issue => issue.Field == "graph.nodes.config.implementation");
Assert.Contains(_validator.Validate(functionWorkflow), issue => issue.Field == "graph.nodes.config.functionRef");
IReadOnlyList<WorkflowValidationIssueDto> requestPortIssues = _validator.Validate(requestPortWorkflow);
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.portId");
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.requestType");
Assert.Contains(requestPortIssues, issue => issue.Field == "graph.nodes.config.responseType");
}
private static WorkflowDefinitionDto CreateWorkflow(string id = "workflow-1")
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Loop Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "agent",
Kind = "agent",
Label = "Agent",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent",
Name = "Agent",
Model = "gpt-5.4",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-agent",
Source = "start",
Target = "agent",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-agent-end",
Source = "agent",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
private static WorkflowDefinitionDto CreateSubworkflowParent(
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowDefinitionDto
{
Id = "workflow-parent",
Name = "Parent Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "sub-workflow",
Kind = "sub-workflow",
Label = "Nested Workflow",
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-sub",
Source = "start",
Target = "sub-workflow",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-sub-end",
Source = "sub-workflow",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
private static WorkflowDefinitionDto CreateSingleNodeWorkflow(string nodeKind, WorkflowNodeConfigDto config)
{
return new WorkflowDefinitionDto
{
Id = $"workflow-{nodeKind}",
Name = $"{nodeKind} Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = nodeKind,
Kind = nodeKind,
Label = nodeKind,
Config = config,
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-node",
Source = "start",
Target = nodeKind,
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-node-end",
Source = nodeKind,
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
}
+1200 -161
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import { basename } from 'node:path';
import type {
ProjectGitCommitMessageSuggestion,
ProjectGitConventionalCommitType,
ProjectGitRunChangeSummary,
} from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
interface BuildCommitMessageSuggestionInput {
session: Pick<SessionRecord, 'messages' | 'title'>;
run: Pick<SessionRunRecord, 'triggerMessageId'>;
summary?: ProjectGitRunChangeSummary;
conventionalType?: ProjectGitConventionalCommitType;
}
const COMMIT_TYPES: readonly ProjectGitConventionalCommitType[] = [
'feat',
'fix',
'refactor',
'docs',
'test',
'chore',
];
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function isCommitType(value: string | undefined): value is ProjectGitConventionalCommitType {
return value !== undefined && COMMIT_TYPES.includes(value as ProjectGitConventionalCommitType);
}
function findTriggerMessageContent(
session: Pick<SessionRecord, 'messages'>,
triggerMessageId: string,
): string | undefined {
return session.messages.find((message) => message.id === triggerMessageId)?.content;
}
function inferCommitTypeFromSummary(
prompt: string | undefined,
summary: ProjectGitRunChangeSummary | undefined,
): ProjectGitConventionalCommitType {
const promptText = normalizeWhitespace(prompt?.toLowerCase() ?? '');
const files = summary?.files ?? [];
const filePaths = files.map((file) => file.path.toLowerCase());
if (filePaths.length > 0 && filePaths.every((path) => path.endsWith('.md') || path.includes('readme'))) {
return 'docs';
}
if (filePaths.length > 0 && filePaths.every((path) => path.includes('test') || path.endsWith('.snap'))) {
return 'test';
}
if (/\b(fix|bug|error|issue|regression|broken|failure)\b/.test(promptText)) {
return 'fix';
}
if (/\b(refactor|cleanup|restructure|rename|simplify)\b/.test(promptText)) {
return 'refactor';
}
if (/\b(doc|readme|documentation)\b/.test(promptText)) {
return 'docs';
}
if (/\b(test|coverage|assertion)\b/.test(promptText)) {
return 'test';
}
if (/\b(chore|config|build|deps|dependency|tooling)\b/.test(promptText)) {
return 'chore';
}
return 'feat';
}
function stripPromptLead(text: string): string {
return text
.replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, '')
.replace(/^(please\s+)?(can|could|would)\s+you\s+/i, '')
.replace(/^(implement|add|create|build|make|update|improve|refactor|fix|support|handle)\s+/i, '')
.replace(/[.?!:;]+$/g, '')
.trim();
}
function summarizeFiles(summary: ProjectGitRunChangeSummary | undefined): string | undefined {
const firstFile = summary?.files[0];
if (!summary || summary.files.length === 0 || !firstFile) {
return undefined;
}
if (summary.files.length === 1) {
return basename(firstFile.path).replace(/\.[^.]+$/, '');
}
return `${summary.fileCount} files`;
}
function buildSubject(
prompt: string | undefined,
summary: ProjectGitRunChangeSummary | undefined,
): string {
const normalizedPrompt = normalizeWhitespace(prompt ?? '');
if (normalizedPrompt) {
const firstSentence = normalizedPrompt.split(/[\r\n.?!]/, 1)[0] ?? normalizedPrompt;
const stripped = stripPromptLead(firstSentence);
if (stripped) {
return stripped
.replace(/\b(the|a|an)\s+/gi, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
}
const fallback = summarizeFiles(summary);
if (fallback) {
return `update ${fallback}`.toLowerCase();
}
return 'update project changes';
}
export function buildProjectGitCommitMessageSuggestion(
input: BuildCommitMessageSuggestionInput,
): ProjectGitCommitMessageSuggestion {
const prompt = findTriggerMessageContent(input.session, input.run.triggerMessageId);
const type = isCommitType(input.conventionalType)
? input.conventionalType
: inferCommitTypeFromSummary(prompt, input.summary);
const subject = buildSubject(prompt, input.summary);
return {
type,
subject,
message: `${type}: ${subject}`,
};
}
+327
View File
@@ -0,0 +1,327 @@
import { Buffer } from 'node:buffer';
import { isUtf8 } from 'node:buffer';
import type {
ProjectGitBaselineFile,
ProjectGitDiffPreview,
ProjectGitRunChangeCounts,
ProjectGitRunChangeKind,
ProjectGitRunChangeSummary,
ProjectGitRunChangedFile,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
interface DiffStats {
additions: number;
deletions: number;
}
interface BuildProjectGitRunChangeSummaryInput {
generatedAt: string;
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
postRunSnapshot?: ProjectGitWorkingTreeSnapshot;
postRunBaselineFiles?: readonly ProjectGitBaselineFile[];
}
function parseDiffStats(diff: string | undefined): DiffStats {
if (!diff) {
return { additions: 0, deletions: 0 };
}
let additions = 0;
let deletions = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions += 1;
}
}
return { additions, deletions };
}
function isBinaryDiff(diff: string | undefined): boolean {
if (!diff) {
return false;
}
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
}
function decodeUtf8FromBase64(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const buffer = Buffer.from(value, 'base64');
return isUtf8(buffer) ? buffer.toString('utf8') : undefined;
}
function canRestoreBaseline(
baseline: ProjectGitBaselineFile | undefined,
): baseline is ProjectGitBaselineFile {
return baseline !== undefined
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
}
function previewFromBaselineFile(
file: Pick<ProjectGitWorkingTreeFile, 'path'>,
baseline: ProjectGitBaselineFile | undefined,
): ProjectGitDiffPreview | undefined {
if (!baseline) {
return undefined;
}
if (baseline.untrackedContentBase64 !== undefined) {
return {
path: file.path,
previousPath: baseline.previousPath,
newFileContents: decodeUtf8FromBase64(baseline.untrackedContentBase64),
...(baseline.isBinary ? { isBinary: true } : {}),
};
}
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
return undefined;
}
return {
path: file.path,
previousPath: baseline.previousPath,
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
? { diff: baseline.combinedDiff }
: {}),
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
};
}
function sameWorkingTreeFile(
left: ProjectGitWorkingTreeFile,
right: ProjectGitWorkingTreeFile,
): boolean {
return (
left.path === right.path
&& left.previousPath === right.previousPath
&& left.stagedStatus === right.stagedStatus
&& left.unstagedStatus === right.unstagedStatus
&& left.isConflicted === right.isConflicted
);
}
function sameBaselineFile(
left: ProjectGitBaselineFile | undefined,
right: ProjectGitBaselineFile | undefined,
): boolean {
if (!left && !right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.path === right.path
&& left.previousPath === right.previousPath
&& left.combinedDiff === right.combinedDiff
&& left.untrackedContentBase64 === right.untrackedContentBase64
&& left.isBinary === right.isBinary
);
}
function createRunChangeCounts(): ProjectGitRunChangeCounts {
return {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 0,
};
}
function incrementRunChangeCount(
counts: ProjectGitRunChangeCounts,
kind: ProjectGitRunChangeKind,
): void {
switch (kind) {
case 'added':
counts.added += 1;
break;
case 'modified':
counts.modified += 1;
break;
case 'deleted':
counts.deleted += 1;
break;
case 'renamed':
counts.renamed += 1;
break;
case 'copied':
counts.copied += 1;
break;
case 'type-changed':
counts.typeChanged += 1;
break;
case 'unmerged':
counts.unmerged += 1;
break;
case 'untracked':
counts.untracked += 1;
break;
case 'cleaned':
counts.cleaned += 1;
break;
}
}
function resolveRunChangeKind(file: ProjectGitWorkingTreeFile): ProjectGitRunChangeKind {
if (file.isConflicted) {
return 'unmerged';
}
return file.unstagedStatus ?? file.stagedStatus ?? 'modified';
}
function sortRunChangedFiles(
left: ProjectGitRunChangedFile,
right: ProjectGitRunChangedFile,
): number {
if (left.origin !== right.origin) {
return left.origin === 'run-created' ? -1 : 1;
}
return left.path.localeCompare(right.path);
}
export function buildProjectGitRunChangeSummary(
input: BuildProjectGitRunChangeSummaryInput,
): ProjectGitRunChangeSummary | undefined {
const {
generatedAt,
preRunSnapshot,
preRunBaselineFiles,
postRunSnapshot,
postRunBaselineFiles,
} = input;
if (!preRunSnapshot || !postRunSnapshot) {
return undefined;
}
const preRunFilesByPath = new Map(
preRunSnapshot.files.map((file) => [file.path, file] satisfies [string, ProjectGitWorkingTreeFile]),
);
const preRunBaselineByPath = new Map(
(preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
const postRunBaselineByPath = new Map(
(postRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
const matchedPreRunPaths = new Set<string>();
const files: ProjectGitRunChangedFile[] = [];
for (const postRunFile of postRunSnapshot.files) {
const matchedPreRunFile = preRunFilesByPath.get(postRunFile.path)
?? (postRunFile.previousPath ? preRunFilesByPath.get(postRunFile.previousPath) : undefined);
const matchedPreRunPath = matchedPreRunFile?.path;
const postRunBaseline = postRunBaselineByPath.get(postRunFile.path);
if (!matchedPreRunFile || !matchedPreRunPath) {
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: postRunFile.path,
previousPath: postRunFile.previousPath,
kind: resolveRunChangeKind(postRunFile),
origin: 'run-created',
stagedStatus: postRunFile.stagedStatus,
unstagedStatus: postRunFile.unstagedStatus,
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: true,
...(preview ? { preview } : {}),
});
continue;
}
matchedPreRunPaths.add(matchedPreRunPath);
const preRunBaseline = preRunBaselineByPath.get(matchedPreRunPath);
if (sameWorkingTreeFile(matchedPreRunFile, postRunFile) && sameBaselineFile(preRunBaseline, postRunBaseline)) {
continue;
}
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: postRunFile.path,
previousPath: postRunFile.previousPath,
kind: resolveRunChangeKind(postRunFile),
origin: 'pre-existing',
stagedStatus: postRunFile.stagedStatus,
unstagedStatus: postRunFile.unstagedStatus,
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: canRestoreBaseline(preRunBaseline),
...(preview ? { preview } : {}),
});
}
for (const preRunFile of preRunSnapshot.files) {
if (matchedPreRunPaths.has(preRunFile.path)) {
continue;
}
const preRunBaseline = preRunBaselineByPath.get(preRunFile.path);
const preview = previewFromBaselineFile(preRunFile, preRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: preRunFile.path,
previousPath: preRunFile.previousPath,
kind: 'cleaned',
origin: 'pre-existing',
stagedStatus: preRunFile.stagedStatus,
unstagedStatus: preRunFile.unstagedStatus,
...(preRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: canRestoreBaseline(preRunBaseline),
...(preview ? { preview } : {}),
});
}
const branchChanged = preRunSnapshot.branch !== postRunSnapshot.branch;
if (files.length === 0 && !branchChanged) {
return undefined;
}
files.sort(sortRunChangedFiles);
const counts = createRunChangeCounts();
let additions = 0;
let deletions = 0;
for (const file of files) {
incrementRunChangeCount(counts, file.kind);
additions += file.additions;
deletions += file.deletions;
}
return {
generatedAt,
branchAtStart: preRunSnapshot.branch,
branchAtEnd: postRunSnapshot.branch,
...(branchChanged ? { branchChanged: true } : {}),
fileCount: files.length,
additions,
deletions,
counts,
files,
};
}
+614 -7
View File
@@ -1,9 +1,30 @@
import { isUtf8 } from 'node:buffer';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project';
import type {
ProjectGitBaselineFile,
ProjectGitBranchSummary,
ProjectGitChangeSummary,
ProjectGitCommitLogEntry,
ProjectGitCommitSummary,
ProjectGitContext,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitFileReference,
ProjectGitRunChangeSummary,
ProjectGitRunChangedFile,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeFileStatus,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary';
type ExecFileException = import('node:child_process').ExecFileException;
const require = createRequire(import.meta.url);
@@ -11,6 +32,16 @@ const { execFile } = require('node:child_process') as typeof import('node:child_
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 5_000;
/** Ensure `filePath` resolves inside `basePath`; throws on traversal. */
function assertPathInsideBase(basePath: string, filePath: string): string {
const resolved = resolve(basePath, filePath);
const rel = relative(resolve(basePath), resolved);
if (rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`Path traversal detected: "${filePath}" escapes base directory.`);
}
return resolved;
}
type GitCommandRunner = (projectPath: string, args: string[]) => Promise<string>;
type GitCommandResult =
@@ -73,6 +104,11 @@ function isNotRepository(error: GitCommandFailure): boolean {
return detail.includes('not a git repository');
}
function isUnknownPathspec(error: GitCommandFailure): boolean {
const detail = `${error.message}\n${error.stderr ?? ''}`.toLowerCase();
return detail.includes('did not match any file') || detail.includes('pathspec');
}
function summarizeGitFailure(error: GitCommandFailure): string {
return error.stderr?.trim() || error.message;
}
@@ -102,9 +138,46 @@ function isConflictedStatus(x: string, y: string): boolean {
);
}
function parseChangeSummary(stdout: string): {
function parseWorkingTreeFileStatus(value: string): ProjectGitWorkingTreeFileStatus | undefined {
switch (value) {
case 'A':
return 'added';
case 'M':
return 'modified';
case 'D':
return 'deleted';
case 'R':
return 'renamed';
case 'C':
return 'copied';
case 'T':
return 'type-changed';
case 'U':
return 'unmerged';
case '?':
return 'untracked';
default:
return undefined;
}
}
function parseWorkingTreePath(rawPath: string): Pick<ProjectGitWorkingTreeFile, 'path' | 'previousPath'> {
const separator = ' -> ';
const separatorIndex = rawPath.indexOf(separator);
if (separatorIndex < 0) {
return { path: rawPath };
}
return {
previousPath: rawPath.slice(0, separatorIndex).trim(),
path: rawPath.slice(separatorIndex + separator.length).trim(),
};
}
function parseWorkingTree(stdout: string): {
changedFileCount: number;
changes: ProjectGitChangeSummary;
files: ProjectGitWorkingTreeFile[];
} {
const summary: ProjectGitChangeSummary = {
staged: 0,
@@ -112,6 +185,7 @@ function parseChangeSummary(stdout: string): {
untracked: 0,
conflicted: 0,
};
const files: ProjectGitWorkingTreeFile[] = [];
const lines = stdout
.split(/\r?\n/)
@@ -122,20 +196,42 @@ function parseChangeSummary(stdout: string): {
for (const line of lines) {
if (line.startsWith('??')) {
const path = line.slice(3).trim();
if (!path) {
continue;
}
summary.untracked += 1;
changedFileCount += 1;
files.push({
path,
unstagedStatus: 'untracked',
});
continue;
}
if (line.length < 2) {
if (line.length < 3) {
continue;
}
const x = line[0];
const y = line[1];
changedFileCount += 1;
const rawPath = line.slice(3).trim();
if (!rawPath) {
continue;
}
if (isConflictedStatus(x, y)) {
changedFileCount += 1;
const isConflicted = isConflictedStatus(x, y);
const pathInfo = parseWorkingTreePath(rawPath);
files.push({
...pathInfo,
stagedStatus: parseWorkingTreeFileStatus(x),
unstagedStatus: parseWorkingTreeFileStatus(y),
...(isConflicted ? { isConflicted: true } : {}),
});
if (isConflicted) {
summary.conflicted += 1;
continue;
}
@@ -152,6 +248,7 @@ function parseChangeSummary(stdout: string): {
return {
changedFileCount,
changes: summary,
files,
};
}
@@ -173,6 +270,122 @@ function parseHead(stdout: string): ProjectGitCommitSummary | undefined {
};
}
function parseBranchList(stdout: string): ProjectGitBranchSummary[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const [name, currentMarker, upstream] = line.split('\0');
const trimmedName = name?.trim();
if (!trimmedName) {
return [];
}
return [{
name: trimmedName,
isCurrent: currentMarker?.trim() === '*',
upstream: upstream?.trim() || undefined,
}];
});
}
function parseCommitLog(stdout: string): ProjectGitCommitLogEntry[] {
return stdout
.split('\x1e')
.map((record) => record.trim())
.filter(Boolean)
.flatMap((record) => {
const [hash, shortHash, authorName, subject, committedAt, refNames] = record.split('\0');
if (!hash || !shortHash || !authorName || !subject || !committedAt) {
return [];
}
return [{
hash: hash.trim(),
shortHash: shortHash.trim(),
authorName: authorName.trim(),
subject: subject.trim(),
committedAt: committedAt.trim(),
refNames: refNames?.trim() || undefined,
}];
});
}
function isPureUntrackedFile(file: ProjectGitWorkingTreeFile): boolean {
return file.stagedStatus === undefined && file.unstagedStatus === 'untracked';
}
function buildGitPaths(file: ProjectGitFileReference): string[] {
const paths = new Set<string>();
if (file.previousPath?.trim()) {
paths.add(file.previousPath.trim());
}
if (file.path.trim()) {
paths.add(file.path.trim());
}
return [...paths];
}
function uniqueGitPaths(files: readonly ProjectGitFileReference[]): string[] {
const paths = new Set<string>();
for (const file of files) {
for (const path of buildGitPaths(file)) {
paths.add(path);
}
}
return [...paths];
}
function isBinaryDiff(diff: string | undefined): boolean {
if (!diff) {
return false;
}
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
}
function canRestoreBaseline(
baseline: ProjectGitBaselineFile | undefined,
): baseline is ProjectGitBaselineFile {
return baseline !== undefined
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
}
function baselineToPreview(
file: Pick<ProjectGitFileReference, 'path' | 'previousPath'>,
baseline: ProjectGitBaselineFile | undefined,
): ProjectGitDiffPreview | undefined {
if (!baseline) {
return undefined;
}
if (baseline.untrackedContentBase64 !== undefined) {
const contentBuffer = Buffer.from(baseline.untrackedContentBase64, 'base64');
return {
path: file.path,
previousPath: file.previousPath,
...(isUtf8(contentBuffer) ? { newFileContents: contentBuffer.toString('utf8') } : {}),
...(baseline.isBinary ? { isBinary: true } : {}),
};
}
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
return undefined;
}
return {
path: file.path,
previousPath: file.previousPath,
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
? { diff: baseline.combinedDiff }
: {}),
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
};
}
export class GitService {
constructor(private readonly runGitCommand: GitCommandRunner = defaultGitCommandRunner) {}
@@ -219,7 +432,7 @@ export class GitService {
this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']),
]);
const { changedFileCount, changes } = parseChangeSummary(statusResult.stdout);
const { changedFileCount, changes } = parseWorkingTree(statusResult.stdout);
const upstream = upstreamResult.ok ? upstreamResult.stdout.trim() || undefined : undefined;
const aheadBehind = countsResult.ok ? parseAheadBehind(countsResult.stdout) : {};
@@ -238,11 +451,405 @@ export class GitService {
};
}
async describeProjectGitDetails(
projectPath: string,
scannedAt = nowIso(),
commitLimit = 20,
): Promise<ProjectGitDetails> {
const context = await this.describeProject(projectPath, scannedAt);
if (context.status !== 'ready') {
return {
scannedAt,
context,
branches: [],
recentCommits: [],
};
}
const [workingTree, branches, recentCommits] = await Promise.all([
this.captureWorkingTreeSnapshot(projectPath, scannedAt),
this.listBranches(projectPath),
this.listRecentCommits(projectPath, commitLimit),
]);
return {
scannedAt,
context,
workingTree: workingTree ?? undefined,
branches,
recentCommits,
};
}
async captureWorkingTreeSnapshot(
projectPath: string,
scannedAt = nowIso(),
): Promise<ProjectGitWorkingTreeSnapshot | undefined> {
const repoRootResult = await this.tryRun(projectPath, ['rev-parse', '--show-toplevel']);
if (!repoRootResult.ok) {
return undefined;
}
const statusResult = await this.tryRun(projectPath, ['status', '--porcelain=1', '--untracked-files=all']);
if (!statusResult.ok) {
return undefined;
}
const branchResult = await this.tryRun(projectPath, ['branch', '--show-current']);
const { changedFileCount, changes, files } = parseWorkingTree(statusResult.stdout);
return {
scannedAt,
repoRoot: repoRootResult.stdout.trim(),
branch: branchResult.ok ? parseBranch(branchResult.stdout) : undefined,
changedFileCount,
changes,
files,
};
}
async captureWorkingTreeBaseline(
projectPath: string,
snapshot?: ProjectGitWorkingTreeSnapshot,
): Promise<ProjectGitBaselineFile[]> {
const effectiveSnapshot = snapshot ?? await this.captureWorkingTreeSnapshot(projectPath);
if (!effectiveSnapshot || effectiveSnapshot.files.length === 0) {
return [];
}
const baselineFiles = await Promise.all(
effectiveSnapshot.files.map((file) => this.captureBaselineFile(projectPath, file)),
);
return baselineFiles.flatMap((file) => (file ? [file] : []));
}
async computeRunChangeSummary(
projectPath: string,
options: {
generatedAt?: string;
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
},
): Promise<ProjectGitRunChangeSummary | undefined> {
const generatedAt = options.generatedAt ?? nowIso();
const postRunSnapshot = await this.captureWorkingTreeSnapshot(projectPath, generatedAt);
if (!options.preRunSnapshot || !postRunSnapshot) {
return undefined;
}
const postRunBaselineFiles = await this.captureWorkingTreeBaseline(projectPath, postRunSnapshot);
return buildProjectGitRunChangeSummary({
generatedAt,
preRunSnapshot: options.preRunSnapshot,
preRunBaselineFiles: options.preRunBaselineFiles,
postRunSnapshot,
postRunBaselineFiles,
});
}
async getWorkingTreeFilePreview(
projectPath: string,
file: ProjectGitFileReference,
): Promise<ProjectGitDiffPreview | undefined> {
const snapshot = await this.captureWorkingTreeSnapshot(projectPath);
if (!snapshot) {
return undefined;
}
const matchedFile = snapshot.files.find((candidate) =>
candidate.path === file.path
|| candidate.previousPath === file.path
|| (file.previousPath !== undefined && candidate.path === file.previousPath)
|| (file.previousPath !== undefined && candidate.previousPath === file.previousPath));
if (!matchedFile) {
return undefined;
}
const baseline = await this.captureBaselineFile(projectPath, matchedFile);
return baselineToPreview(matchedFile, baseline);
}
async stageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
const paths = uniqueGitPaths(files);
if (paths.length === 0) {
return;
}
await this.run(projectPath, ['add', '--', ...paths]);
}
async unstageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
const paths = uniqueGitPaths(files);
if (paths.length === 0) {
return;
}
await this.run(projectPath, ['restore', '--staged', '--', ...paths]);
}
async commit(projectPath: string, message: string): Promise<ProjectGitCommitSummary> {
await this.run(projectPath, ['commit', '-m', message]);
const head = await this.getHeadCommit(projectPath);
if (!head) {
throw new Error('Git commit completed, but the new HEAD commit could not be resolved.');
}
return head;
}
async push(projectPath: string): Promise<void> {
await this.run(projectPath, ['push']);
}
async fetch(projectPath: string): Promise<void> {
await this.run(projectPath, ['fetch', '--all', '--prune']);
}
async pull(projectPath: string, rebase = false): Promise<void> {
await this.run(projectPath, rebase ? ['pull', '--rebase'] : ['pull']);
}
async createBranch(
projectPath: string,
name: string,
startPoint?: string,
checkout = true,
): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
if (checkout) {
await this.run(
projectPath,
['switch', '-c', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
);
return;
}
await this.run(
projectPath,
['branch', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
);
}
async switchBranch(projectPath: string, name: string): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
await this.run(projectPath, ['switch', trimmedName]);
}
async deleteBranch(projectPath: string, name: string, force = false): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
await this.run(projectPath, ['branch', force ? '-D' : '-d', trimmedName]);
}
async listBranches(projectPath: string): Promise<ProjectGitBranchSummary[]> {
const result = await this.tryRun(projectPath, [
'for-each-ref',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)',
'refs/heads',
]);
return result.ok ? parseBranchList(result.stdout) : [];
}
async listRecentCommits(projectPath: string, limit = 20): Promise<ProjectGitCommitLogEntry[]> {
const result = await this.tryRun(projectPath, [
'log',
`-n${Math.max(1, Math.round(limit))}`,
'--format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e',
]);
return result.ok ? parseCommitLog(result.stdout) : [];
}
async discardRunChanges(
projectPath: string,
options: {
summary: ProjectGitRunChangeSummary;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
files?: readonly ProjectGitFileReference[];
},
): Promise<void> {
const selectedFiles = options.files && options.files.length > 0
? options.summary.files.filter((candidate) =>
options.files?.some((selected) =>
selected.path === candidate.path
|| selected.path === candidate.previousPath
|| (selected.previousPath !== undefined && selected.previousPath === candidate.previousPath)))
: options.summary.files;
if (selectedFiles.length === 0) {
return;
}
const baselinesByPath = new Map(
(options.preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
for (const file of selectedFiles) {
if (file.origin === 'pre-existing') {
if (!file.canRevert) {
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
}
const baseline = baselinesByPath.get(file.previousPath ?? file.path) ?? baselinesByPath.get(file.path);
if (!canRestoreBaseline(baseline)) {
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
}
await this.restorePreExistingChange(projectPath, file, baseline);
continue;
}
await this.restoreRunCreatedChange(projectPath, file);
}
}
private async captureBaselineFile(
projectPath: string,
file: ProjectGitWorkingTreeFile,
): Promise<ProjectGitBaselineFile | undefined> {
if (!file.path.trim()) {
return undefined;
}
if (isPureUntrackedFile(file)) {
try {
const contents = await readFile(assertPathInsideBase(projectPath, file.path));
return {
path: file.path,
previousPath: file.previousPath,
untrackedContentBase64: contents.toString('base64'),
...(isUtf8(contents) ? {} : { isBinary: true }),
};
} catch {
return {
path: file.path,
previousPath: file.previousPath,
};
}
}
const diffPaths = buildGitPaths(file);
const diffResult = await this.tryRun(projectPath, [
'diff',
'--binary',
'--no-ext-diff',
'--no-renames',
'HEAD',
'--',
...diffPaths,
]);
if (!diffResult.ok) {
return {
path: file.path,
previousPath: file.previousPath,
};
}
return {
path: file.path,
previousPath: file.previousPath,
combinedDiff: diffResult.stdout.trim() ? diffResult.stdout : undefined,
...(isBinaryDiff(diffResult.stdout) ? { isBinary: true } : {}),
};
}
private async getHeadCommit(projectPath: string): Promise<ProjectGitCommitSummary | undefined> {
const result = await this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']);
return result.ok ? parseHead(result.stdout) : undefined;
}
private async restoreRunCreatedChange(
projectPath: string,
file: ProjectGitRunChangedFile,
): Promise<void> {
if (file.kind === 'renamed' && file.previousPath) {
await this.restorePathFromHead(projectPath, file.previousPath);
await this.removePath(projectPath, file.path);
return;
}
if (file.kind === 'added' || file.kind === 'untracked' || file.kind === 'copied') {
await this.removePath(projectPath, file.path);
return;
}
await this.restorePathFromHead(projectPath, file.path);
}
private async restorePreExistingChange(
projectPath: string,
file: ProjectGitRunChangedFile,
baseline: ProjectGitBaselineFile,
): Promise<void> {
await this.restorePathFromHead(projectPath, baseline.path);
if (file.path !== baseline.path) {
await this.removePath(projectPath, file.path);
}
if (baseline.untrackedContentBase64 !== undefined) {
const contents = Buffer.from(baseline.untrackedContentBase64, 'base64');
await mkdir(dirname(join(projectPath, baseline.path)), { recursive: true });
await writeFile(join(projectPath, baseline.path), contents);
return;
}
if (baseline.combinedDiff) {
await this.applyPatch(projectPath, baseline.combinedDiff);
}
}
private async restorePathFromHead(projectPath: string, path: string): Promise<void> {
const result = await this.tryRun(projectPath, ['restore', '--source=HEAD', '--staged', '--worktree', '--', path]);
if (!result.ok && !isUnknownPathspec(result.error)) {
throw result.error;
}
}
private async removePath(projectPath: string, path: string): Promise<void> {
const unstageResult = await this.tryRun(projectPath, ['rm', '--cached', '--force', '--ignore-unmatch', '--', path]);
if (!unstageResult.ok && !isUnknownPathspec(unstageResult.error)) {
throw unstageResult.error;
}
await rm(assertPathInsideBase(projectPath, path), { force: true });
}
private async applyPatch(projectPath: string, diff: string): Promise<void> {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-patch-'));
const patchPath = join(tempDirectory, 'restore.diff');
try {
await writeFile(patchPath, diff, 'utf8');
await this.run(projectPath, ['apply', '--whitespace=nowarn', '--recount', patchPath]);
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
}
private async run(projectPath: string, args: string[]): Promise<string> {
try {
return await this.runGitCommand(projectPath, args);
} catch (error) {
throw createGitCommandFailure(projectPath, args, error);
}
}
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
try {
return {
ok: true,
stdout: await this.runGitCommand(projectPath, args),
stdout: await this.run(projectPath, args),
};
} catch (error) {
return {
+115 -10
View File
@@ -5,13 +5,29 @@ import { ipcChannels } from '@shared/contracts/channels';
import type {
BranchSessionInput,
CancelSessionTurnInput,
CommitProjectGitChangesInput,
CreateSessionInput,
CreateWorkflowFromTemplateInput,
CreateWorkflowSessionInput,
CreateProjectGitBranchInput,
DismissSessionMcpAuthInput,
DismissSessionPlanReviewInput,
DeleteProjectGitBranchInput,
DeleteSessionInput,
DiscardSessionRunGitChangesInput,
EditAndResendSessionMessageInput,
ExportWorkflowInput,
ImportWorkflowInput,
ProjectGitDetailsInput,
ProjectGitFilePreviewInput,
ProjectGitFileSelectionInput,
ProjectGitInput,
PullProjectGitInput,
RegenerateSessionMessageInput,
ResolveWorkspaceDiscoveredToolingInput,
StartSessionMcpAuthInput,
SuggestProjectGitCommitMessageInput,
SwitchProjectGitBranchInput,
DuplicateSessionInput,
RenameSessionInput,
RescanProjectConfigsInput,
@@ -19,12 +35,12 @@ import type {
ResolveProjectDiscoveredToolingInput,
ResolveSessionApprovalInput,
ResolveSessionUserInputInput,
ResolveWorkspaceDiscoveredToolingInput,
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SaveWorkflowInput,
SaveWorkflowTemplateInput,
SaveWorkspaceAgentInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
SetProjectAgentProfileEnabledInput,
SetSessionArchivedInput,
SetSessionInteractionModeInput,
@@ -52,6 +68,12 @@ export function registerIpcHandlers(
service: AryxAppService,
autoUpdateService: AutoUpdateService,
): void {
window.on('focus', () => {
if (service.isGitAutoRefreshEnabled()) {
service.scheduleProjectGitRefresh();
}
});
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
@@ -65,6 +87,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
service.refreshProjectGitContext(projectId),
);
ipcMain.handle(ipcChannels.getProjectGitDetails, (_event, input: ProjectGitDetailsInput) =>
service.getProjectGitDetails(input.projectId, input.commitLimit),
);
ipcMain.handle(ipcChannels.getProjectGitFilePreview, (_event, input: ProjectGitFilePreviewInput) =>
service.getProjectGitFilePreview(input.projectId, input.file),
);
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
service.rescanProjectConfigs(input.projectId),
);
@@ -83,10 +111,22 @@ export function registerIpcHandlers(
(_event, input: SetProjectAgentProfileEnabledInput) =>
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
);
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
service.setPatternFavorite(input.patternId, input.isFavorite),
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
ipcMain.handle(ipcChannels.saveWorkflowTemplate, (_event, input: SaveWorkflowTemplateInput) =>
service.saveWorkflowTemplate(input.workflowId, input.options),
);
ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId));
ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) =>
service.listWorkflowReferences(workflowId),
);
ipcMain.handle(ipcChannels.createWorkflowFromTemplate, (_event, input: CreateWorkflowFromTemplateInput) =>
service.createWorkflowFromTemplate(input.templateId, input.options),
);
ipcMain.handle(ipcChannels.exportWorkflow, (_event, input: ExportWorkflowInput) =>
service.exportWorkflow(input.workflowId, input.format),
);
ipcMain.handle(ipcChannels.importWorkflow, (_event, input: ImportWorkflowInput) =>
service.importWorkflow(input.content, input.format, input.options),
);
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
const result = await service.setTheme(theme);
@@ -105,6 +145,10 @@ export function registerIpcHandlers(
ipcChannels.setMinimizeToTray,
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
);
ipcMain.handle(
ipcChannels.setGitAutoRefreshEnabled,
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
);
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
ipcMain.handle(ipcChannels.installUpdate, () => {
autoUpdateService.installUpdate();
@@ -121,6 +165,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
service.deleteLspProfile(profileId),
);
ipcMain.handle(ipcChannels.saveWorkspaceAgent, (_event, input: SaveWorkspaceAgentInput) =>
service.saveWorkspaceAgent(input.agent),
);
ipcMain.handle(ipcChannels.deleteWorkspaceAgent, (_event, agentId: string) =>
service.deleteWorkspaceAgent(agentId),
);
ipcMain.handle(ipcChannels.describeTerminal, () => service.describeTerminal());
ipcMain.handle(ipcChannels.createTerminal, () => service.createTerminal());
ipcMain.handle(ipcChannels.restartTerminal, () => service.restartTerminal());
@@ -144,7 +194,10 @@ export function registerIpcHandlers(
service.updateSessionApprovalSettings(input.sessionId, input.autoApprovedToolNames),
);
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
service.createSession(input.projectId, input.patternId),
service.createSession(input.projectId, input.workflowId),
);
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
service.createWorkflowSession(input.projectId, input.workflowId),
);
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
service.duplicateSession(input.sessionId),
@@ -174,7 +227,13 @@ export function registerIpcHandlers(
service.editAndResendSessionMessage(input.sessionId, input.messageId, input.content, input.attachments),
);
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
service.sendSessionMessage(
input.sessionId,
input.content,
input.attachments,
input.messageMode,
input.promptInvocation,
),
);
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
service.cancelSessionTurn(input.sessionId),
@@ -197,14 +256,60 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.startSessionMcpAuth, (_event, input: StartSessionMcpAuthInput) =>
service.startSessionMcpAuth(input.sessionId),
);
ipcMain.handle(
ipcChannels.discardSessionRunGitChanges,
(_event, input: DiscardSessionRunGitChangesInput) =>
service.discardSessionRunGitChanges(input.sessionId, input.runId, input.files),
);
ipcMain.handle(
ipcChannels.suggestProjectGitCommitMessage,
(_event, input: SuggestProjectGitCommitMessageInput) =>
service.suggestProjectGitCommitMessage(input.sessionId, input.runId, input.conventionalType),
);
ipcMain.handle(
ipcChannels.updateSessionModelConfig,
(_event, input: UpdateSessionModelConfigInput) =>
service.updateSessionModelConfig(input.sessionId, input.model, input.reasoningEffort),
);
ipcMain.handle(
ipcChannels.stageProjectGitFiles,
(_event, input: ProjectGitFileSelectionInput) => service.stageProjectGitFiles(input.projectId, input.files),
);
ipcMain.handle(
ipcChannels.unstageProjectGitFiles,
(_event, input: ProjectGitFileSelectionInput) => service.unstageProjectGitFiles(input.projectId, input.files),
);
ipcMain.handle(
ipcChannels.commitProjectGitChanges,
(_event, input: CommitProjectGitChangesInput) =>
service.commitProjectGitChanges(input.projectId, input.message, input.files, input.push),
);
ipcMain.handle(ipcChannels.pushProjectGit, (_event, input: ProjectGitInput) =>
service.pushProjectGit(input.projectId),
);
ipcMain.handle(ipcChannels.fetchProjectGit, (_event, input: ProjectGitInput) =>
service.fetchProjectGit(input.projectId),
);
ipcMain.handle(ipcChannels.pullProjectGit, (_event, input: PullProjectGitInput) =>
service.pullProjectGit(input.projectId, input.rebase),
);
ipcMain.handle(
ipcChannels.createProjectGitBranch,
(_event, input: CreateProjectGitBranchInput) =>
service.createProjectGitBranch(input.projectId, input.name, input.startPoint, input.checkout),
);
ipcMain.handle(
ipcChannels.switchProjectGitBranch,
(_event, input: SwitchProjectGitBranchInput) =>
service.switchProjectGitBranch(input.projectId, input.name),
);
ipcMain.handle(
ipcChannels.deleteProjectGitBranch,
(_event, input: DeleteProjectGitBranchInput) =>
service.deleteProjectGitBranch(input.projectId, input.name, input.force),
);
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
+96 -51
View File
@@ -1,22 +1,34 @@
import { mkdir } from 'node:fs/promises';
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
import {
normalizeChatMessageRecord,
normalizeSessionBranchOrigin,
type SessionRecord,
} from '@shared/domain/session';
import {
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
} from '@shared/domain/tooling';
import {
createBuiltinWorkflowTemplates,
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import {
createBuiltinWorkflows,
normalizeWorkflowDefinition,
type WorkflowDefinition,
} from '@shared/domain/workflow';
import {
applyDefaultToolApprovalPolicy,
normalizePendingApprovalState,
normalizeSessionApprovalSettings,
} from '@shared/domain/approval';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
import {
@@ -26,28 +38,47 @@ import {
} from '@main/persistence/appPaths';
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
const builtinTimestamp = nowIso();
const builtinPatterns = createBuiltinPatterns(builtinTimestamp);
const builtinIds = new Set(builtinPatterns.map((pattern) => pattern.id));
const existingMap = new Map(existingPatterns.map((pattern) => [pattern.id, pattern]));
function mergeBuiltinWorkflows(existingWorkflows: WorkflowDefinition[]): WorkflowDefinition[] {
const builtinWorkflows = createBuiltinWorkflows(nowIso());
const builtinIds = new Set(builtinWorkflows.map((workflow) => workflow.id));
const mergedBuiltins = builtinPatterns.map((builtin) => {
const existing = existingMap.get(builtin.id);
if (!existing) {
return builtin;
const customWorkflows = existingWorkflows
.filter((workflow) => !builtinIds.has(workflow.id))
.map(normalizeWorkflowDefinition);
return [...builtinWorkflows, ...customWorkflows];
}
function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[]): WorkflowTemplateDefinition[] {
const builtinTemplates = createBuiltinWorkflowTemplates(nowIso());
const builtinIds = new Set(builtinTemplates.map((template) => template.id));
const customTemplates = existingTemplates
.map(normalizeWorkflowTemplateDefinition)
.filter((template) => template.source !== 'builtin' && !builtinIds.has(template.id));
return [...builtinTemplates, ...customTemplates];
}
function migrateLegacySessions(
sessions: SessionRecord[],
workflows: WorkflowDefinition[],
): SessionRecord[] {
const workflowIds = new Set(workflows.map((workflow) => workflow.id));
const fallbackWorkflowId = workflows[0]?.id;
return sessions.flatMap((session) => {
const workflowId = session.workflowId && workflowIds.has(session.workflowId)
? session.workflowId
: fallbackWorkflowId;
if (!workflowId) {
return [];
}
return {
...existing,
availability: builtin.availability,
unavailabilityReason: builtin.unavailabilityReason,
mode: builtin.mode,
};
return [{
...session,
workflowId,
}];
});
const customPatterns = existingPatterns.filter((pattern) => !builtinIds.has(pattern.id));
return [...mergedBuiltins, ...customPatterns];
}
export class WorkspaceRepository {
@@ -57,7 +88,7 @@ export class WorkspaceRepository {
async load(): Promise<WorkspaceState> {
await mkdir(this.scratchpadPath, { recursive: true });
const stored = await readJsonFile<WorkspaceState>(this.filePath);
const stored = await readJsonFile<WorkspaceState & { patterns?: unknown[] }>(this.filePath);
if (!stored) {
const seededBase = createWorkspaceSeed();
const projects = mergeScratchpadProject([], this.scratchpadPath);
@@ -78,44 +109,58 @@ export class WorkspaceRepository {
})),
this.scratchpadPath,
);
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
...normalizePendingApprovalState({
pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue,
}),
};
if (!isScratchpadProject(normalizedSession.projectId)) {
return normalizedSession;
}
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
await mkdir(cwd, { recursive: true });
return {
...normalizedSession,
cwd,
};
}));
const workflows = mergeBuiltinWorkflows((stored.workflows ?? []).map(normalizeWorkflowDefinition))
.map((workflow) => ({
...workflow,
settings: {
...workflow.settings,
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
},
}));
const sessions = migrateLegacySessions(
await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
...normalizePendingApprovalState({
pendingApproval: session.pendingApproval,
pendingApprovalQueue: session.pendingApprovalQueue,
}),
};
if (!isScratchpadProject(normalizedSession.projectId)) {
return normalizedSession;
}
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
await mkdir(cwd, { recursive: true });
return {
...normalizedSession,
cwd,
};
})),
workflows,
);
const settings = normalizeWorkspaceSettings(stored.settings);
const workspace: WorkspaceState = {
...stored,
patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({
...pattern,
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
graph: resolvePatternGraph(pattern),
})),
workflows,
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
projects,
sessions,
settings,
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
? stored.selectedProjectId
: projects[0]?.id,
selectedWorkflowId: workflows.some((workflow) => workflow.id === stored.selectedWorkflowId)
? stored.selectedWorkflowId
: workflows[0]?.id,
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
};
+272 -102
View File
@@ -7,12 +7,15 @@ import {
mergeProjectCustomizationState,
normalizeProjectCustomizationState,
type ProjectAgentProfile,
type ProjectInstructionApplicationMode,
type ProjectCustomizationState,
type ProjectInstructionFile,
type ProjectPromptFile,
type ProjectPromptVariable,
} from '@shared/domain/projectCustomization';
import { nowIso } from '@shared/utils/ids';
import { expandMarkdownFileLinks } from '@main/services/projectCustomizationLinkResolver';
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
@@ -22,9 +25,21 @@ export class ProjectCustomizationScanner {
current?: ProjectCustomizationState,
): Promise<ProjectCustomizationState> {
const previous = normalizeProjectCustomizationState(current);
const instructions = await this.scanInstructionFiles(projectPath, previous);
const agentProfiles = await this.scanAgentProfiles(projectPath, previous);
const promptFiles = await this.scanPromptFiles(projectPath, previous);
const customizationRoots = await resolveProjectCustomizationRoots(projectPath);
const allowedRootPath = customizationRoots.at(-1) ?? projectPath;
const instructions = await this.scanInstructionFiles(
projectPath,
customizationRoots,
allowedRootPath,
previous,
);
const agentProfiles = await this.scanAgentProfiles(projectPath, customizationRoots, previous);
const promptFiles = await this.scanPromptFiles(
projectPath,
customizationRoots,
allowedRootPath,
previous,
);
return mergeProjectCustomizationState(
previous,
@@ -39,37 +54,61 @@ export class ProjectCustomizationScanner {
private async scanInstructionFiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
allowedRootPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectInstructionFile[]> {
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
const sourcePaths = ['.github\\copilot-instructions.md', 'AGENTS.md'] as const;
const instructions: ProjectInstructionFile[] = [];
for (const sourcePath of sourcePaths) {
const filePath = join(projectPath, ...sourcePath.split('\\'));
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
continue;
}
for (const customizationRoot of customizationRoots) {
const alwaysOnSourcePaths = [
'.github\\copilot-instructions.md',
'AGENTS.md',
'CLAUDE.md',
'.claude\\CLAUDE.md',
] as const;
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
instructions.push(existing);
for (const sourcePath of alwaysOnSourcePaths) {
const filePath = join(customizationRoot, ...sourcePath.split('\\'));
const normalizedSourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, normalizedSourcePath, previousByPath, {
applicationMode: 'always',
projectPath,
allowedRootPath,
});
if (instruction) {
instructions.push(instruction);
}
continue;
}
const content = contents.value.trim();
if (!content) {
continue;
const instructionFilePaths = await this.listProjectFiles(
join(customizationRoot, '.github', 'instructions'),
'.instructions.md',
);
for (const filePath of instructionFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
projectPath,
allowedRootPath,
});
if (instruction) {
instructions.push(instruction);
}
}
instructions.push({
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
});
const claudeRuleFilePaths = await this.listProjectFiles(join(customizationRoot, '.claude', 'rules'), '.md');
for (const filePath of claudeRuleFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
projectPath,
allowedRootPath,
usesClaudeRulePaths: true,
});
if (instruction) {
instructions.push(instruction);
}
}
}
return instructions;
@@ -77,55 +116,58 @@ export class ProjectCustomizationScanner {
private async scanAgentProfiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
previous: ProjectCustomizationState,
): Promise<ProjectAgentProfile[]> {
const previousByPath = new Map(previous.agentProfiles.map((profile) => [profile.sourcePath, profile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'agents'), '.agent.md');
const profiles: ProjectAgentProfile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
for (const customizationRoot of customizationRoots) {
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'agents'), '.agent.md');
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
if (contents.kind === 'missing') {
continue;
}
continue;
}
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
}
}
return profiles;
@@ -133,69 +175,92 @@ export class ProjectCustomizationScanner {
private async scanPromptFiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
allowedRootPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectPromptFile[]> {
const previousByPath = new Map(previous.promptFiles.map((promptFile) => [promptFile.sourcePath, promptFile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'prompts'), '.prompt.md');
const promptFiles: ProjectPromptFile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
for (const customizationRoot of customizationRoots) {
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'prompts'), '.prompt.md');
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
if (contents.kind === 'missing') {
continue;
}
continue;
}
const template = parsedFile.body.trim();
if (!template) {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
template,
variables: extractPromptVariables(template),
sourcePath,
});
const template = await expandMarkdownFileLinks(parsedFile.body, {
sourceFilePath: filePath,
projectPath,
allowedRootPath,
});
if (!template) {
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
argumentHint: readOptionalString(parsedFile.attributes, ['argument-hint', 'argumentHint']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
model: readOptionalString(parsedFile.attributes, ['model']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
template,
variables: extractPromptVariables(template),
sourcePath,
});
}
}
return promptFiles;
}
private async listProjectFiles(directoryPath: string, suffix: string): Promise<string[]> {
const filePaths: string[] = [];
await this.collectProjectFiles(directoryPath, suffix, filePaths);
return filePaths.sort((left, right) => left.localeCompare(right));
}
private async collectProjectFiles(directoryPath: string, suffix: string, filePaths: string[]): Promise<void> {
try {
const entries = await readdir(directoryPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(suffix))
.map((entry) => join(directoryPath, entry.name))
.sort((left, right) => left.localeCompare(right));
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = join(directoryPath, entry.name);
if (entry.isDirectory()) {
await this.collectProjectFiles(entryPath, suffix, filePaths);
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith(suffix)) {
filePaths.push(entryPath);
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
return;
}
console.warn(`[aryx customization] Failed to read directory ${directoryPath}:`, error);
return [];
}
}
@@ -218,6 +283,65 @@ export class ProjectCustomizationScanner {
return { kind: 'retain-previous' };
}
}
private async scanInstructionFile(
filePath: string,
sourcePath: string,
previousByPath: ReadonlyMap<string, ProjectInstructionFile>,
options: {
applicationMode?: ProjectInstructionApplicationMode;
projectPath: string;
allowedRootPath: string;
usesClaudeRulePaths?: boolean;
},
): Promise<ProjectInstructionFile | undefined> {
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
return undefined;
}
if (contents.kind === 'retain-previous') {
return previousByPath.get(sourcePath);
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
return previousByPath.get(sourcePath);
}
const content = await expandMarkdownFileLinks(parsedFile.body, {
sourceFilePath: filePath,
projectPath: options.projectPath,
allowedRootPath: options.allowedRootPath,
});
if (!content) {
return undefined;
}
const description = readOptionalString(parsedFile.attributes, ['description']);
const applyTo = readInstructionApplyTo(parsedFile.attributes, options.usesClaudeRulePaths === true);
const instruction: ProjectInstructionFile = {
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
applicationMode: options.applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
};
const name = readOptionalString(parsedFile.attributes, ['name']);
if (name) {
instruction.name = name;
}
if (description) {
instruction.description = description;
}
if (applyTo) {
instruction.applyTo = applyTo;
}
return instruction;
}
}
function parseProjectFrontmatter(
@@ -309,6 +433,43 @@ function readOptionalString(
return undefined;
}
function readInstructionApplyTo(
record: Record<string, unknown>,
usesClaudeRulePaths: boolean,
): string | undefined {
const applyTo = readOptionalString(record, ['applyTo']);
const paths = readOptionalStringArray(record.paths);
if (paths && paths.length > 0) {
return paths.join(',');
}
if (applyTo) {
return applyTo;
}
return usesClaudeRulePaths ? '**' : undefined;
}
function resolveInstructionApplicationMode(
applyTo: string | undefined,
description: string | undefined,
): ProjectInstructionApplicationMode {
if (isMatchAllInstructionGlob(applyTo)) {
return 'always';
}
if (applyTo) {
return 'file';
}
if (description) {
return 'task';
}
return 'manual';
}
function readOptionalStringArray(value: unknown): string[] | undefined {
if (value === undefined) {
return undefined;
@@ -368,3 +529,12 @@ function normalizeYamlValue(value: unknown): unknown {
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isMatchAllInstructionGlob(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalizedValue = value.trim().replaceAll('\\', '/');
return normalizedValue === '**' || normalizedValue === '**/*';
}
@@ -0,0 +1,202 @@
import { readFile } from 'node:fs/promises';
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
type MarkdownLinkResolutionContext = {
projectPath: string;
allowedRootPath: string;
sourceFilePath: string;
seenPaths: Set<string>;
ancestry: readonly string[];
};
export async function expandMarkdownFileLinks(
content: string,
options: {
projectPath: string;
allowedRootPath: string;
sourceFilePath: string;
},
): Promise<string> {
const trimmedContent = content.trim();
if (!trimmedContent) {
return trimmedContent;
}
return expandMarkdownFileLinksRecursive(trimmedContent, {
...options,
seenPaths: new Set<string>(),
ancestry: [options.sourceFilePath],
});
}
async function expandMarkdownFileLinksRecursive(
content: string,
context: MarkdownLinkResolutionContext,
): Promise<string> {
const referencedBlocks: string[] = [];
for (const linkTarget of collectLocalMarkdownLinkTargets(content)) {
const resolvedPath = resolveMarkdownLinkTarget(context.sourceFilePath, linkTarget);
if (!resolvedPath) {
continue;
}
if (!isPathInsideRoot(resolvedPath, context.allowedRootPath)) {
console.warn(
`[aryx customization] Ignoring linked file outside the allowed customization root: ${resolvedPath}`,
);
continue;
}
if (context.seenPaths.has(resolvedPath)) {
continue;
}
if (context.ancestry.includes(resolvedPath)) {
console.warn(`[aryx customization] Ignoring circular Markdown link reference to ${resolvedPath}.`);
continue;
}
const linkedContent = await readLinkedFile(resolvedPath);
if (linkedContent === undefined) {
continue;
}
context.seenPaths.add(resolvedPath);
const expandedLinkedContent = isMarkdownLikePath(resolvedPath)
? await expandMarkdownFileLinksRecursive(linkedContent, {
...context,
sourceFilePath: resolvedPath,
ancestry: [...context.ancestry, resolvedPath],
})
: linkedContent.trim();
referencedBlocks.push(
formatReferencedFileBlock(
toProjectSourcePath(context.projectPath, resolvedPath),
expandedLinkedContent,
),
);
}
if (referencedBlocks.length === 0) {
return content.trim();
}
return `${content.trim()}\n\nReferenced file context:\n\n${referencedBlocks.join('\n\n')}`.trim();
}
function collectLocalMarkdownLinkTargets(content: string): string[] {
const targets: string[] = [];
const seenTargets = new Set<string>();
let match: RegExpExecArray | null;
while ((match = markdownLinkPattern.exec(content))) {
const target = match[1]?.trim();
if (!target || seenTargets.has(target)) {
continue;
}
seenTargets.add(target);
targets.push(target);
}
markdownLinkPattern.lastIndex = 0;
return targets;
}
function resolveMarkdownLinkTarget(sourceFilePath: string, rawTarget: string): string | undefined {
const target = extractMarkdownLinkDestination(rawTarget);
if (!target) {
return undefined;
}
return resolve(dirname(sourceFilePath), target);
}
function extractMarkdownLinkDestination(rawTarget: string): string | undefined {
let target = rawTarget.trim();
if (!target) {
return undefined;
}
if (target.startsWith('<') && target.endsWith('>')) {
target = target.slice(1, -1).trim();
} else {
const whitespaceIndex = target.search(/\s/);
if (whitespaceIndex >= 0) {
target = target.slice(0, whitespaceIndex);
}
}
const hashIndex = target.indexOf('#');
if (hashIndex >= 0) {
target = target.slice(0, hashIndex);
}
if (!target) {
return undefined;
}
const normalizedTarget = target.toLowerCase();
if (
target.startsWith('#')
|| isAbsolute(target)
|| normalizedTarget.startsWith('http://')
|| normalizedTarget.startsWith('https://')
|| normalizedTarget.startsWith('mailto:')
|| normalizedTarget.startsWith('vscode:')
|| normalizedTarget.startsWith('command:')
|| normalizedTarget.startsWith('data:')
) {
return undefined;
}
return target;
}
async function readLinkedFile(filePath: string): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
if (content.includes('\0')) {
console.warn(`[aryx customization] Ignoring binary-linked file ${filePath}.`);
return undefined;
}
return content.trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
console.warn(`[aryx customization] Linked file not found: ${filePath}`);
return undefined;
}
console.warn(`[aryx customization] Failed to read linked file ${filePath}:`, error);
return undefined;
}
}
function formatReferencedFileBlock(sourcePath: string, content: string): string {
return [
`Source: ${sourcePath}`,
'Contents:',
content.trim() || '[empty file]',
].join('\n');
}
function isMarkdownLikePath(filePath: string): boolean {
const normalizedPath = filePath.toLowerCase();
return normalizedPath.endsWith('.md') || normalizedPath.endsWith('.markdown');
}
function isPathInsideRoot(filePath: string, rootPath: string): boolean {
const relativePath = relative(rootPath, filePath);
return relativePath.length === 0
|| (!relativePath.startsWith('..') && !isAbsolute(relativePath));
}
function toProjectSourcePath(projectPath: string, filePath: string): string {
const relativePath = relative(projectPath, filePath).trim();
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
}
@@ -0,0 +1,38 @@
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function resolveProjectCustomizationRoots(projectPath: string): Promise<string[]> {
if (await hasGitEntry(projectPath)) {
return [projectPath];
}
const ancestorPaths: string[] = [];
let currentPath = projectPath;
while (true) {
const parentPath = dirname(currentPath);
if (parentPath === currentPath) {
return [projectPath];
}
ancestorPaths.push(parentPath);
if (await hasGitEntry(parentPath)) {
return [projectPath, ...ancestorPaths];
}
currentPath = parentPath;
}
}
async function hasGitEntry(directoryPath: string): Promise<boolean> {
try {
await access(join(directoryPath, '.git'));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
console.warn(`[aryx customization] Failed to inspect ${join(directoryPath, '.git')}:`, error);
return false;
}
}
@@ -0,0 +1,175 @@
import { watch } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
export interface ProjectCustomizationWatchTarget {
id: string;
path: string;
}
type ProjectWatchHandle = {
close(): void;
};
type ProjectWatchFactory = (
directoryPath: string,
onChange: () => void,
) => ProjectWatchHandle;
type ProjectWatchPathResolver = (projectPath: string) => Promise<string[]>;
export class ProjectCustomizationWatcher {
private readonly watchFactory: ProjectWatchFactory;
private readonly resolveWatchPaths: ProjectWatchPathResolver;
private readonly debounceMs: number;
private readonly watchHandlesByProjectId = new Map<string, Map<string, ProjectWatchHandle>>();
private readonly pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly onChange: (projectId: string) => void | Promise<void>,
options?: {
watchFactory?: ProjectWatchFactory;
resolveWatchPaths?: ProjectWatchPathResolver;
debounceMs?: number;
},
) {
this.watchFactory = options?.watchFactory ?? createProjectWatchHandle;
this.resolveWatchPaths = options?.resolveWatchPaths ?? collectProjectCustomizationWatchPaths;
this.debounceMs = options?.debounceMs ?? 250;
}
async syncProjects(projects: ReadonlyArray<ProjectCustomizationWatchTarget>): Promise<void> {
const nextProjectsById = new Map(projects.map((project) => [project.id, project]));
for (const projectId of this.watchHandlesByProjectId.keys()) {
if (!nextProjectsById.has(projectId)) {
this.unwatchProject(projectId);
}
}
for (const project of projects) {
await this.syncProject(project);
}
}
dispose(): void {
for (const projectId of this.watchHandlesByProjectId.keys()) {
this.unwatchProject(projectId);
}
}
private async syncProject(project: ProjectCustomizationWatchTarget): Promise<void> {
const nextWatchPaths = new Set(await this.resolveWatchPaths(project.path));
const currentWatchHandles = this.watchHandlesByProjectId.get(project.id) ?? new Map<string, ProjectWatchHandle>();
for (const [watchPath, handle] of currentWatchHandles) {
if (nextWatchPaths.has(watchPath)) {
continue;
}
handle.close();
currentWatchHandles.delete(watchPath);
}
for (const watchPath of nextWatchPaths) {
if (currentWatchHandles.has(watchPath)) {
continue;
}
try {
currentWatchHandles.set(watchPath, this.watchFactory(watchPath, () => this.scheduleChange(project.id)));
} catch (error) {
console.warn(`[aryx customization] Failed to watch ${watchPath}:`, error);
}
}
if (currentWatchHandles.size > 0) {
this.watchHandlesByProjectId.set(project.id, currentWatchHandles);
return;
}
this.watchHandlesByProjectId.delete(project.id);
}
private unwatchProject(projectId: string): void {
const timer = this.pendingTimers.get(projectId);
if (timer) {
clearTimeout(timer);
this.pendingTimers.delete(projectId);
}
const watchHandles = this.watchHandlesByProjectId.get(projectId);
if (!watchHandles) {
return;
}
for (const handle of watchHandles.values()) {
handle.close();
}
this.watchHandlesByProjectId.delete(projectId);
}
private scheduleChange(projectId: string): void {
const existingTimer = this.pendingTimers.get(projectId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
this.pendingTimers.delete(projectId);
void Promise.resolve(this.onChange(projectId)).catch((error) => {
console.warn(`[aryx customization] Failed to process watcher update for ${projectId}:`, error);
});
}, this.debounceMs);
timer.unref?.();
this.pendingTimers.set(projectId, timer);
}
}
export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise<string[]> {
const paths = new Set<string>();
for (const customizationRoot of await resolveProjectCustomizationRoots(projectPath)) {
paths.add(customizationRoot);
for (const relativeRoot of ['.github', '.claude']) {
for (const directoryPath of await collectExistingDirectories(join(customizationRoot, relativeRoot))) {
paths.add(directoryPath);
}
}
}
return [...paths].sort((left, right) => left.localeCompare(right));
}
async function collectExistingDirectories(rootPath: string): Promise<string[]> {
try {
const directories = [rootPath];
const entries = await readdir(rootPath, { withFileTypes: true });
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (!entry.isDirectory()) {
continue;
}
directories.push(...await collectExistingDirectories(join(rootPath, entry.name)));
}
return directories;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
console.warn(`[aryx customization] Failed to enumerate watch paths under ${rootPath}:`, error);
return [];
}
}
function createProjectWatchHandle(directoryPath: string, onChange: () => void): ProjectWatchHandle {
return watch(directoryPath, { persistent: false }, () => {
onChange();
});
}
+5 -1
View File
@@ -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';
+22 -16
View File
@@ -13,7 +13,7 @@ import type {
UserInputRequestedEvent,
McpOauthRequiredEvent,
ExitPlanModeRequestedEvent,
ValidatePatternCommand,
ValidateWorkflowCommand,
RunTurnCommand,
CopilotSessionListFilter,
CopilotSessionInfo,
@@ -42,8 +42,8 @@ type PendingCommand =
})
| ({
processId: number;
kind: 'validate-pattern';
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
kind: 'validate-workflow';
resolve: (issues: ValidateWorkflowCommand['workflow'] extends never ? never : unknown) => void;
reject: (error: Error) => void;
})
| ({
@@ -119,11 +119,15 @@ export class SidecarClient {
return command;
}
async validatePattern(pattern: ValidatePatternCommand['pattern']): Promise<unknown> {
async validateWorkflow(
workflow: ValidateWorkflowCommand['workflow'],
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
): Promise<unknown> {
return this.dispatch<unknown>({
type: 'validate-pattern',
requestId: `validate-${Date.now()}`,
pattern,
type: 'validate-workflow',
requestId: `validate-workflow-${Date.now()}`,
workflow,
workflowLibrary,
});
}
@@ -310,10 +314,10 @@ export class SidecarClient {
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
errored: false,
});
} else if (command.type === 'validate-pattern') {
} else if (command.type === 'validate-workflow') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'validate-pattern',
kind: 'validate-workflow',
resolve: resolve as (issues: unknown) => void,
reject,
});
@@ -407,8 +411,8 @@ export class SidecarClient {
this.pending.delete(event.requestId);
}
return;
case 'pattern-validation':
if (pending.kind === 'validate-pattern') {
case 'workflow-validation':
if (pending.kind === 'validate-workflow') {
pending.resolve(event.issues);
this.pending.delete(event.requestId);
}
@@ -452,11 +456,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));
}
+24 -4
View File
@@ -14,6 +14,8 @@ const api: ElectronApi = {
resolveWorkspaceDiscoveredTooling: (input) =>
ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input),
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
getProjectGitDetails: (input) => ipcRenderer.invoke(ipcChannels.getProjectGitDetails, input),
getProjectGitFilePreview: (input) => ipcRenderer.invoke(ipcChannels.getProjectGitFilePreview, input),
rescanProjectConfigs: (input) => ipcRenderer.invoke(ipcChannels.rescanProjectConfigs, input),
rescanProjectCustomization: (input) =>
ipcRenderer.invoke(ipcChannels.rescanProjectCustomization, input),
@@ -21,19 +23,27 @@ const api: ElectronApi = {
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
setProjectAgentProfileEnabled: (input) =>
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, workflowId),
createWorkflowFromTemplate: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowFromTemplate, input),
exportWorkflow: (input) => ipcRenderer.invoke(ipcChannels.exportWorkflow, input),
importWorkflow: (input) => ipcRenderer.invoke(ipcChannels.importWorkflow, input),
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled),
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
saveWorkspaceAgent: (input) => ipcRenderer.invoke(ipcChannels.saveWorkspaceAgent, input),
deleteWorkspaceAgent: (agentId) => ipcRenderer.invoke(ipcChannels.deleteWorkspaceAgent, agentId),
describeTerminal: () => ipcRenderer.invoke(ipcChannels.describeTerminal),
createTerminal: () => ipcRenderer.invoke(ipcChannels.createTerminal),
restartTerminal: () => ipcRenderer.invoke(ipcChannels.restartTerminal),
@@ -65,11 +75,21 @@ const api: ElectronApi = {
dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input),
dismissSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionMcpAuth, input),
startSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.startSessionMcpAuth, input),
discardSessionRunGitChanges: (input) => ipcRenderer.invoke(ipcChannels.discardSessionRunGitChanges, input),
suggestProjectGitCommitMessage: (input) => ipcRenderer.invoke(ipcChannels.suggestProjectGitCommitMessage, input),
updateSessionModelConfig: (input) =>
ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input),
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
stageProjectGitFiles: (input) => ipcRenderer.invoke(ipcChannels.stageProjectGitFiles, input),
unstageProjectGitFiles: (input) => ipcRenderer.invoke(ipcChannels.unstageProjectGitFiles, input),
commitProjectGitChanges: (input) => ipcRenderer.invoke(ipcChannels.commitProjectGitChanges, input),
pushProjectGit: (input) => ipcRenderer.invoke(ipcChannels.pushProjectGit, input),
fetchProjectGit: (input) => ipcRenderer.invoke(ipcChannels.fetchProjectGit, input),
pullProjectGit: (input) => ipcRenderer.invoke(ipcChannels.pullProjectGit, input),
createProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.createProjectGitBranch, input),
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
+256 -115
View File
@@ -1,18 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CommitComposer } from '@renderer/components/chat/CommitComposer';
import { AppShell } from '@renderer/components/AppShell';
import { ActivityPanel } from '@renderer/components/ActivityPanel';
import { ChatPane } from '@renderer/components/ChatPane';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
import { NewSessionModal } from '@renderer/components/NewSessionModal';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import { TerminalPanel, DEFAULT_HEIGHT as DEFAULT_TERMINAL_HEIGHT, MIN_HEIGHT as MIN_TERMINAL_HEIGHT } from '@renderer/components/TerminalPanel';
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
import { GitPanel } from '@renderer/components/GitPanel';
import { TerminalPanel } from '@renderer/components/TerminalPanel';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import {
applySessionEventActivity,
@@ -23,6 +25,7 @@ import {
pruneSessionUsage,
pruneSessionRequestUsage,
pruneTurnEventLogs,
purgeCompletedActivity,
type SessionActivityMap,
type SessionUsageMap,
type SessionRequestUsageMap,
@@ -36,43 +39,20 @@ import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
import {
buildAvailableModelCatalog,
findModel,
normalizePatternModels,
normalizeWorkflowModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import { createDefaultToolApprovalPolicy } from '@shared/domain/approval';
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import { syncPatternGraph, type PatternDefinition } from '@shared/domain/pattern';
import { type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import type { ProjectGitFileReference } from '@shared/domain/project';
import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
import { createId, nowIso } from '@shared/utils/ids';
function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition {
const timestamp = nowIso();
return syncPatternGraph({
id: createId('custom-pattern'),
name: 'New Pattern',
description: '',
mode: 'single',
availability: 'available',
maxIterations: 1,
approvalPolicy: createDefaultToolApprovalPolicy(),
agents: [
{
id: createId('agent'),
name: 'Primary Agent',
description: 'General-purpose assistant.',
instructions: 'You are a helpful coding assistant working inside the selected project.',
model: defaultModelId,
reasoningEffort: defaultReasoningEffort,
},
],
createdAt: timestamp,
updatedAt: timestamp,
});
}
import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker';
function createDraftMcpServer(): McpServerDefinition {
const timestamp = nowIso();
@@ -102,6 +82,64 @@ function createDraftLspProfile(): LspProfileDefinition {
};
}
function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefinition {
const timestamp = nowIso();
return {
id: createId('agent'),
name: '',
description: '',
instructions: '',
model: defaultModelId,
reasoningEffort: 'high',
createdAt: timestamp,
updatedAt: timestamp,
};
}
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort?: ReasoningEffort): WorkflowDefinition {
const timestamp = nowIso();
const startId = createId('wf-start');
const agentId = createId('wf-agent');
const endId = createId('wf-end');
return {
id: createId('workflow'),
name: 'New Workflow',
description: '',
graph: {
nodes: [
{ id: startId, kind: 'start', label: 'Start', position: { x: 0, y: 100 }, config: { kind: 'start' } },
{
id: agentId,
kind: 'agent',
label: 'Primary Agent',
position: { x: 250, y: 100 },
config: {
kind: 'agent',
id: createId('agent'),
name: 'Primary Agent',
description: 'General-purpose assistant.',
instructions: 'You are a helpful coding assistant working inside the selected project.',
model: defaultModelId,
reasoningEffort: defaultReasoningEffort,
},
},
{ id: endId, kind: 'end', label: 'End', position: { x: 500, y: 100 }, config: { kind: 'end' } },
],
edges: [
{ id: `edge-${startId}-to-${agentId}`, source: startId, target: agentId, kind: 'direct' },
{ id: `edge-${agentId}-to-${endId}`, source: agentId, target: endId, kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
maxIterations: 5,
},
createdAt: timestamp,
updatedAt: timestamp,
};
}
export default function App() {
const api = getElectronApi();
const [workspace, setWorkspace] = useState<WorkspaceState>();
@@ -112,24 +150,32 @@ export default function App() {
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
const activityPurgeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
const [projectSettingsId, setProjectSettingsId] = useState<string>();
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
const [showDiscoveryModal, setShowDiscoveryModal] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showBookmarks, setShowBookmarks] = useState(false);
// Terminal state
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalHeight, setTerminalHeight] = useState(
() => workspace?.settings.terminalHeight ?? DEFAULT_TERMINAL_HEIGHT,
// Workflow picker state — holds the projectId we're creating a session for
const [workflowPickerProjectId, setWorkflowPickerProjectId] = useState<string | null>(null);
// Commit composer state
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
// Bottom panel state (terminal + git)
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
const [bottomPanelHeight, setBottomPanelHeight] = useState(
() => workspace?.settings.terminalHeight ?? DEFAULT_BOTTOM_HEIGHT,
);
const [terminalRunning, setTerminalRunning] = useState(false);
const [gitDirty, setGitDirty] = useState(false);
// Load workspace on mount
useEffect(() => {
@@ -182,12 +228,35 @@ export default function App() {
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
setTurnEventLogs((current) => applyTurnEventLog(current, event));
setActiveSubagents((current) => applySubagentEvent(current, event));
// Schedule purge of completed activity labels after grace period
if (event.kind === 'status' && event.status === 'idle') {
const existing = activityPurgeTimers.current.get(event.sessionId);
if (existing) clearTimeout(existing);
activityPurgeTimers.current.set(
event.sessionId,
setTimeout(() => {
setSessionActivities((current) => purgeCompletedActivity(current, event.sessionId));
activityPurgeTimers.current.delete(event.sessionId);
}, 1500),
);
}
// Cancel pending purge if a new run starts
if (event.kind === 'status' && event.status === 'running') {
const existing = activityPurgeTimers.current.get(event.sessionId);
if (existing) {
clearTimeout(existing);
activityPurgeTimers.current.delete(event.sessionId);
}
}
});
return () => {
disposed = true;
offWorkspace();
offSessionEvent();
for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer);
activityPurgeTimers.current.clear();
};
}, [api]);
@@ -217,23 +286,23 @@ export default function App() {
() => buildAvailableModelCatalog(sidecarCapabilities?.models),
[sidecarCapabilities?.models],
);
const patternForSession = useMemo(() => {
const workflowForSession = useMemo(() => {
if (!selectedSession) {
return undefined;
}
const basePattern = workspace?.patterns.find((pattern) => pattern.id === selectedSession.patternId);
if (!basePattern) {
const baseWorkflow = workspace?.workflows.find((workflow) => workflow.id === selectedSession.workflowId);
if (!baseWorkflow) {
return undefined;
}
const patternWithSessionConfig =
const workflowWithSessionConfig =
projectForSession && selectedSession.sessionModelConfig
? applySessionModelConfig(basePattern, selectedSession)
: basePattern;
? applySessionModelConfig(baseWorkflow, selectedSession)
: baseWorkflow;
return normalizePatternModels(patternWithSessionConfig, availableModels);
}, [availableModels, projectForSession, selectedSession, workspace?.patterns]);
return normalizeWorkflowModels(workflowWithSessionConfig, availableModels);
}, [availableModels, projectForSession, selectedSession, workspace?.workflows]);
const activityForSession = useMemo(
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities],
@@ -292,8 +361,6 @@ export default function App() {
commandPaletteOpenRef.current = commandPaletteOpen;
const projectSettingsIdRef = useRef(projectSettingsId);
projectSettingsIdRef.current = projectSettingsId;
const newSessionProjectIdRef = useRef(newSessionProjectId);
newSessionProjectIdRef.current = newSessionProjectId;
// ── Global keyboard shortcuts ──
useEffect(() => {
@@ -310,7 +377,7 @@ export default function App() {
// ── Ctrl+` — Toggle terminal ──
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
setTerminalOpen((prev) => !prev);
handleTerminalToggle();
return;
}
@@ -362,11 +429,6 @@ export default function App() {
setShowSettings(false);
return;
}
if (newSessionProjectIdRef.current) {
e.preventDefault();
setNewSessionProjectId(undefined);
return;
}
// If nothing is open, cancel a running turn on the selected session
if (ws) {
@@ -391,7 +453,12 @@ export default function App() {
ws.selectedProjectId ??
ws.projects.find((p) => !isScratchpadProject(p))?.id;
if (defaultProjectId) {
setNewSessionProjectId(defaultProjectId);
if (ws.workflows.length <= 1) {
const wf = ws.workflows[0];
if (wf) void api.createSession({ projectId: defaultProjectId, workflowId: wf.id });
} else {
setWorkflowPickerProjectId(defaultProjectId);
}
}
}
return;
@@ -457,26 +524,38 @@ export default function App() {
};
}, [api]);
// Sync terminalHeight from workspace settings when workspace loads
// Sync bottom panel height from workspace settings when workspace loads
useEffect(() => {
if (workspace?.settings.terminalHeight) {
setTerminalHeight(workspace.settings.terminalHeight);
setBottomPanelHeight(workspace.settings.terminalHeight);
}
}, [workspace?.settings.terminalHeight]);
const handleTerminalHeightChange = useCallback((newHeight: number) => {
const clamped = Math.max(MIN_TERMINAL_HEIGHT, Math.round(newHeight));
setTerminalHeight(clamped);
const handleBottomPanelHeightChange = useCallback((newHeight: number) => {
const clamped = Math.max(MIN_BOTTOM_HEIGHT, Math.round(newHeight));
setBottomPanelHeight(clamped);
void api.setTerminalHeight({ height: clamped });
}, [api]);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
const handleBottomPanelClose = useCallback(() => {
setBottomPanelOpen(false);
}, []);
const handleTerminalToggle = useCallback(() => {
setTerminalOpen((prev) => !prev);
}, []);
setBottomPanelOpen((prev) => {
if (prev && bottomPanelTab === 'terminal') return false;
return true;
});
setBottomPanelTab('terminal');
}, [bottomPanelTab]);
const handleGitToggle = useCallback(() => {
setBottomPanelOpen((prev) => {
if (prev && bottomPanelTab === 'git') return false;
return true;
});
setBottomPanelTab('git');
}, [bottomPanelTab]);
const jumpToMessage = useCallback((messageId: string) => {
const element = document.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`);
@@ -487,22 +566,48 @@ export default function App() {
}
}, []);
const handleDiscardRunChanges = useCallback(
(sessionId: string, runId: string, files?: ProjectGitFileReference[]) =>
api.discardSessionRunGitChanges({ sessionId, runId, files }),
[api],
);
const handleOpenCommitComposer = useCallback(() => {
if (!selectedSession) return;
setCommitComposerCtx({
projectId: selectedSession.projectId,
sessionId: selectedSession.id,
});
}, [selectedSession]);
const handleCreateScratchpad = useCallback(() => {
if (!workspace) return;
const singlePatterns = workspace.patterns
.filter((p) => p.mode === 'single' && p.availability !== 'unavailable')
.sort((a, b) => {
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
return 0;
});
const defaultPattern = singlePatterns[0];
if (defaultPattern) {
void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, patternId: defaultPattern.id });
if (workspace.workflows.length <= 1) {
const wf = workspace.workflows[0];
if (wf) void api.createSession({ projectId: SCRATCHPAD_PROJECT_ID, workflowId: wf.id });
return;
}
setWorkflowPickerProjectId(SCRATCHPAD_PROJECT_ID);
}, [api, workspace]);
/** Opens the workflow picker, or creates immediately if ≤1 workflow. */
const handleNewSession = useCallback((projectId: string) => {
if (!workspace) return;
if (workspace.workflows.length <= 1) {
const wf = workspace.workflows[0];
if (wf) void api.createSession({ projectId, workflowId: wf.id });
return;
}
setWorkflowPickerProjectId(projectId);
}, [api, workspace]);
/** Called when a workflow is picked from the picker. */
const handleWorkflowPicked = useCallback((workflowId: string) => {
if (!workflowPickerProjectId) return;
void api.createSession({ projectId: workflowPickerProjectId, workflowId });
setWorkflowPickerProjectId(null);
}, [api, workflowPickerProjectId]);
const handleOpenSettingsAt = useCallback((section?: SettingsSection) => {
setSettingsSection(section);
setShowSettings(true);
@@ -550,14 +655,15 @@ export default function App() {
</div>
</div>
);
} else if (selectedSession && patternForSession && projectForSession) {
} else if (selectedSession && workflowForSession && projectForSession) {
content = (
<ChatPane
onSend={(c, attachments, messageMode) => api.sendSessionMessage({
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
sessionId: selectedSession.id,
content: c,
attachments: attachments?.length ? attachments : undefined,
messageMode,
promptInvocation,
})}
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
onResolveApproval={(approvalId, decision, alwaysApprove) =>
@@ -618,22 +724,27 @@ export default function App() {
availableModels={availableModels}
mcpProbingServerIds={workspace.mcpProbingServerIds}
onTerminalToggle={handleTerminalToggle}
pattern={patternForSession}
onGitToggle={!isScratchpadProject(selectedSession.projectId) ? handleGitToggle : undefined}
workflow={workflowForSession}
project={projectForSession}
runtimeTools={sidecarCapabilities?.runtimeTools}
session={selectedSession}
sessionUsage={usageForSession}
sessionActivity={activityForSession}
activeSubagents={subagentsForSession}
terminalOpen={terminalOpen}
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
terminalRunning={terminalRunning}
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
gitDirty={gitDirty}
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
onDiscardRunChanges={handleDiscardRunChanges}
onOpenCommitComposer={handleOpenCommitComposer}
/>
);
detailPanel = (
<ActivityPanel
activity={activityForSession}
onJumpToMessage={jumpToMessage}
pattern={patternForSession}
workflow={workflowForSession}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
@@ -664,15 +775,14 @@ export default function App() {
onDeleteMcpServer={async (id) => {
await api.deleteMcpServer(id);
}}
onDeletePattern={async (id) => {
await api.deletePattern(id);
onDeleteWorkflow={async (id) => {
await api.deleteWorkflow(id);
}}
onNewLspProfile={createDraftLspProfile}
onNewMcpServer={createDraftMcpServer}
onNewPattern={() => {
onNewWorkflow={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftPattern(
return createDraftWorkflow(
defaultModel?.id ?? 'gpt-5.4',
resolveReasoningEffort(defaultModel, 'high'),
);
@@ -684,14 +794,27 @@ export default function App() {
onSaveMcpServer={async (server) => {
await api.saveMcpServer({ server });
}}
onSavePattern={async (pattern) => {
await api.savePattern({ pattern });
onSaveWorkflow={async (workflow) => {
await api.saveWorkflow({ workflow });
}}
onSaveWorkspaceAgent={async (agent) => {
await api.saveWorkspaceAgent({ agent });
}}
onDeleteWorkspaceAgent={async (id) => {
await api.deleteWorkspaceAgent(id);
}}
onNewWorkspaceAgent={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftWorkspaceAgent(defaultModel?.id ?? 'gpt-5.4');
}}
workspaceAgents={workspace.settings.agents ?? []}
onSetTheme={(theme) => void api.setTheme(theme)}
notificationsEnabled={workspace.settings.notificationsEnabled !== false}
onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)}
minimizeToTray={workspace.settings.minimizeToTray === true}
onSetMinimizeToTray={(enabled) => void api.setMinimizeToTray(enabled)}
gitAutoRefreshEnabled={workspace.settings.gitAutoRefreshEnabled !== false}
onSetGitAutoRefreshEnabled={(enabled) => void api.setGitAutoRefreshEnabled(enabled)}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onResetLocalWorkspace={async () => {
const fresh = await api.resetLocalWorkspace();
@@ -699,7 +822,11 @@ export default function App() {
setSessionActivities({});
setShowSettings(false);
}}
patterns={workspace.patterns}
workflows={workspace.workflows}
workflowTemplates={workspace.workflowTemplates}
onCreateWorkflowFromTemplate={async (templateId, name) => {
await api.createWorkflowFromTemplate({ templateId, options: name ? { name } : undefined });
}}
sidecarCapabilities={sidecarCapabilities}
theme={workspace.settings.theme}
toolingSettings={workspace.settings.tooling}
@@ -717,13 +844,30 @@ export default function App() {
content={content}
detailPanel={detailPanel}
overlay={overlay}
terminalPanel={
terminalOpen ? (
<TerminalPanel
height={terminalHeight}
onHeightChange={handleTerminalHeightChange}
onClose={handleTerminalClose}
onMinimize={handleTerminalClose}
bottomPanel={
bottomPanelOpen ? (
<BottomPanel
activeTab={bottomPanelTab}
gitContent={
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
<GitPanel
onDirtyChange={setGitDirty}
projectId={selectedSession.projectId}
/>
) : (
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
Git is not available for scratchpad sessions
</div>
)
}
gitDirty={gitDirty}
height={bottomPanelHeight}
onClose={handleBottomPanelClose}
onHeightChange={handleBottomPanelHeightChange}
onTabChange={setBottomPanelTab}
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
terminalRunning={terminalRunning}
/>
) : undefined
}
@@ -731,9 +875,7 @@ export default function App() {
<Sidebar
onAddProject={() => void api.addProject()}
onCreateScratchpad={() => handleCreateScratchpad()}
onNewProjectSession={(projectId) => {
setNewSessionProjectId(projectId);
}}
onNewProjectSession={(projectId) => handleNewSession(projectId)}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
onProjectSelect={(projectId) => {
@@ -768,22 +910,6 @@ export default function App() {
}
/>
{newSessionProjectId && (
<NewSessionModal
defaultProjectId={newSessionProjectId}
onClose={() => setNewSessionProjectId(undefined)}
onCreate={(projectId, patternId) => {
setNewSessionProjectId(undefined);
void api.createSession({ projectId, patternId });
}}
onTogglePatternFavorite={(patternId, isFavorite) => {
void api.setPatternFavorite({ patternId, isFavorite });
}}
patterns={workspace.patterns}
projects={workspace.projects}
/>
)}
{showDiscoveryModal && (
<DiscoveredToolingModal
onClose={() => setShowDiscoveryModal(false)}
@@ -834,9 +960,7 @@ export default function App() {
onSelectProject={(projectId) => {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => {
setNewSessionProjectId(projectId);
}}
onNewSession={(projectId) => handleNewSession(projectId)}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
@@ -885,6 +1009,23 @@ export default function App() {
}}
/>
)}
{commitComposerCtx && (
<CommitComposer
onClose={() => setCommitComposerCtx(undefined)}
projectId={commitComposerCtx.projectId}
runId={commitComposerCtx.runId}
sessionId={commitComposerCtx.sessionId}
/>
)}
{workflowPickerProjectId && workspace && (
<WorkflowPicker
workflows={workspace.workflows}
onSelect={handleWorkflowPicked}
onClose={() => setWorkflowPickerProjectId(null)}
/>
)}
</>
);
}
+23 -33
View File
@@ -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, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
@@ -15,21 +15,19 @@ import {
type SessionRequestUsageState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline';
import { inferProvider } from '@shared/domain/models';
import type { OrchestrationMode, PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
/* ── Mode accent colours ───────────────────────────────────── */
const modeAccent: Record<OrchestrationMode, { dot: string; bar: string; label: string }> = {
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
magentic: { dot: 'bg-[var(--color-text-muted)]', bar: 'bg-[var(--color-text-muted)] opacity-60', label: 'text-[var(--color-text-muted)]' },
};
/* ── Helpers ───────────────────────────────────────────────── */
@@ -49,13 +47,12 @@ function formatEffort(effort: string | undefined): string | undefined {
return labels[effort] ?? effort;
}
const modeLabels: Record<OrchestrationMode, string> = {
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
magentic: 'Magentic',
};
/* ── Section header ────────────────────────────────────────── */
@@ -78,8 +75,8 @@ function AgentRow({
agentUsage,
}: {
row: AgentActivityRow;
agent?: PatternAgentDefinition;
accent: (typeof modeAccent)[OrchestrationMode];
agent?: AgentNodeConfig;
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}) {
@@ -189,6 +186,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)]`} />;
}
@@ -207,8 +206,7 @@ function formatTurnEventTimestamp(iso: string): string {
interface ActivityPanelProps {
activity?: SessionActivityState;
onJumpToMessage?: (messageId: string) => void;
pattern: PatternDefinition;
workflow: WorkflowDefinition;
session: SessionRecord;
sessionRequestUsage?: SessionRequestUsageState;
turnEvents?: TurnEventLog;
@@ -216,22 +214,29 @@ interface ActivityPanelProps {
export function ActivityPanel({
activity,
onJumpToMessage,
pattern,
workflow,
session,
sessionRequestUsage,
turnEvents,
}: ActivityPanelProps) {
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const activityRows = useMemo(
() => buildAgentActivityRows(activity, pattern.agents),
[activity, pattern.agents],
() => buildAgentActivityRows(activity, workflowAgents),
[activity, workflowAgents],
);
const isBusy = session.status === 'running';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
const accent = modeAccent[pattern.mode] ?? modeAccent.single;
const accent = modeAccent[workflowMode] ?? modeAccent.single;
return (
<div className="flex h-full flex-col">
@@ -265,7 +270,7 @@ export function ActivityPanel({
{activityRows.length}
</span>
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
{modeLabels[pattern.mode]}
{modeLabels[workflowMode]}
</span>
</SectionHeader>
@@ -278,7 +283,7 @@ export function ActivityPanel({
return (
<AgentRow
accent={accent}
agent={pattern.agents[index]}
agent={workflowAgents[index]}
agentUsage={agentUsage}
isLast={index === activityRows.length - 1}
key={row.key}
@@ -333,21 +338,6 @@ export function ActivityPanel({
</div>
)}
{/* ── Run timeline section ─────────────────────────── */}
<div className="mb-4">
<SectionHeader>
<Clock className="size-3" />
<span>Timeline</span>
{session.runs.length > 0 && (
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{session.runs.length}
</span>
)}
</SectionHeader>
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
</div>
{/* ── Turn events section ─────────────────────────── */}
{turnEvents && turnEvents.length > 0 && (
<div className="mb-4">
@@ -8,7 +8,7 @@ import {
providerMeta,
type ModelDefinition,
} from '@shared/domain/models';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
import { ProviderIcon } from './ProviderIcons';
+4 -4
View File
@@ -4,11 +4,11 @@ interface AppShellProps {
sidebar: ReactNode;
content: ReactNode;
detailPanel?: ReactNode;
terminalPanel?: ReactNode;
bottomPanel?: ReactNode;
overlay?: ReactNode;
}
export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay }: AppShellProps) {
export function AppShell({ sidebar, content, detailPanel, bottomPanel, overlay }: AppShellProps) {
return (
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-[var(--color-text-primary)]">
{/* Full-width drag region matching the title bar overlay height */}
@@ -19,7 +19,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
{sidebar}
</aside>
{/* Main content + terminal */}
{/* Main content + bottom panel */}
<main className="relative flex min-w-0 flex-1 flex-col">
{/* Ambient glow behind active content area */}
<div
@@ -27,7 +27,7 @@ export function AppShell({ sidebar, content, detailPanel, terminalPanel, overlay
style={{ background: 'var(--gradient-glow)' }}
/>
<div className="relative min-h-0 flex-1">{content}</div>
{terminalPanel}
{bottomPanel}
</main>
{/* Detail panel */}
+168
View File
@@ -0,0 +1,168 @@
import { useCallback, useRef, useState, type ReactNode } from 'react';
import { GitBranch, Minus, TerminalSquare, X } from 'lucide-react';
/* ── Constants ────────────────────────────────────────────── */
const MIN_HEIGHT = 120;
const MAX_HEIGHT_FRACTION = 0.7;
const DEFAULT_HEIGHT = 280;
/* ── Types ────────────────────────────────────────────────── */
export type BottomPanelTab = 'terminal' | 'git';
/* ── BottomPanel ──────────────────────────────────────────── */
interface BottomPanelProps {
activeTab: BottomPanelTab;
onTabChange: (tab: BottomPanelTab) => void;
onClose: () => void;
height: number;
onHeightChange: (height: number) => void;
terminalContent: ReactNode;
gitContent: ReactNode;
showGitTab: boolean;
terminalRunning?: boolean;
gitDirty?: boolean;
}
export function BottomPanel({
activeTab,
onTabChange,
onClose,
height,
onHeightChange,
terminalContent,
gitContent,
showGitTab,
terminalRunning,
gitDirty,
}: BottomPanelProps) {
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragStartRef.current = { y: e.clientY, height };
setIsDragging(true);
const handleDragMove = (moveEvent: MouseEvent) => {
if (!dragStartRef.current) return;
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
const delta = dragStartRef.current.y - moveEvent.clientY;
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
onHeightChange(nextHeight);
};
const handleDragEnd = () => {
setIsDragging(false);
dragStartRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
}, [height, onHeightChange]);
return (
<div
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
style={{ height, minHeight: MIN_HEIGHT }}
>
{/* Resize handle */}
<div
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
onMouseDown={handleDragStart}
role="separator"
aria-orientation="horizontal"
aria-label="Resize panel"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
}
}}
/>
{/* Tab bar */}
<div className="flex h-8 shrink-0 items-center border-b border-[var(--color-border)] px-1">
{/* Terminal tab */}
<button
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
activeTab === 'terminal'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onTabChange('terminal')}
type="button"
role="tab"
aria-selected={activeTab === 'terminal'}
>
{terminalRunning && <span className="size-1.5 shrink-0 rounded-full bg-emerald-400" />}
<TerminalSquare className="size-3" />
Terminal
</button>
{/* Git tab */}
{showGitTab && (
<button
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors duration-100 ${
activeTab === 'git'
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)]/50 hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onTabChange('git')}
type="button"
role="tab"
aria-selected={activeTab === 'git'}
>
{gitDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
<GitBranch className="size-3" />
Git
</button>
)}
<div className="flex-1" />
{/* Minimize */}
<button
aria-label="Minimize panel"
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
>
<Minus className="size-3" />
</button>
{/* Close */}
<button
aria-label="Close panel"
className="rounded p-1 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
onClick={onClose}
type="button"
>
<X className="size-3" />
</button>
</div>
{/* Tab content — both always mounted, only active one visible */}
<div className="relative min-h-0 flex-1">
<div className={`absolute inset-0 flex flex-col ${activeTab === 'terminal' ? '' : 'invisible'}`}>
{terminalContent}
</div>
{showGitTab && (
<div className={`absolute inset-0 flex flex-col overflow-y-auto ${activeTab === 'git' ? '' : 'hidden'}`}>
{gitContent}
</div>
)}
</div>
</div>
);
}
export { DEFAULT_HEIGHT, MIN_HEIGHT };
+395 -100
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bookmark, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react';
import { AlertCircle, ArrowUp, Bookmark, Bot, ChevronDown, ChevronRight, Circle, ClipboardList, FileText, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
@@ -9,17 +9,18 @@ import { MessageEditComposer } from '@renderer/components/chat/MessageEditCompos
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
import { InlineApprovalPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill } from '@renderer/components/chat/InlinePromptPill';
import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
import { InlinePromptPill, type ArmedPrompt } from '@renderer/components/chat/InlinePromptPill';
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess';
import { TurnActivityPanel } from '@renderer/components/chat/TurnActivityPanel';
import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { ApprovalDecision } from '@shared/domain/approval';
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
import type { SessionUsageState, SessionActivityState } from '@renderer/lib/sessionActivity';
import { summarizeSessionActivity } from '@renderer/lib/sessionActivity';
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
import {
findModel,
@@ -27,9 +28,11 @@ import {
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow';
import { isScratchpadProject, type ProjectGitFileReference, type ProjectRecord } from '@shared/domain/project';
import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import {
countApprovedToolsInGroups,
groupApprovalToolsByProvider,
@@ -41,19 +44,35 @@ import {
/* ── ChatPane ──────────────────────────────────────────────── */
type DisplayItem =
| { type: 'message'; message: ChatMessageRecord }
| {
type: 'turn-activity';
thinkingMessages: ChatMessageRecord[];
run?: SessionRunRecord;
turnStartedAt?: string;
/** Agent names in this turn group, derived from thinking message authors. Used to scope run events. */
agentNames?: ReadonlySet<string>;
/** True when this is the last turn-activity panel that shares a given run (controls git summary / discard). */
isLastRunPanel?: boolean;
};
interface ChatPaneProps {
project: ProjectRecord;
pattern: PatternDefinition;
workflow: WorkflowDefinition;
session: SessionRecord;
availableModels: ReadonlyArray<ModelDefinition>;
toolingSettings: WorkspaceToolingSettings;
mcpProbingServerIds?: string[];
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
sessionUsage?: SessionUsageState;
sessionActivity?: SessionActivityState;
activeSubagents?: ReadonlyArray<ActiveSubagent>;
terminalOpen?: boolean;
terminalRunning?: boolean;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
gitPanelOpen?: boolean;
gitDirty?: boolean;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode, promptInvocation?: ProjectPromptInvocation) => Promise<void>;
onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
@@ -62,6 +81,7 @@ interface ChatPaneProps {
onDismissMcpAuth?: () => void;
onAuthenticateMcp?: () => void;
onTerminalToggle?: () => void;
onGitToggle?: () => void;
onUpdateSessionModelConfig?: (config: {
model: string;
reasoningEffort?: ReasoningEffort;
@@ -72,21 +92,26 @@ interface ChatPaneProps {
onPinMessage?: (messageId: string, isPinned: boolean) => void;
onRegenerateMessage?: (messageId: string) => void;
onEditAndResendMessage?: (messageId: string, content: string) => void;
onDiscardRunChanges?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
branchOriginLabel?: string;
}
export function ChatPane({
project,
pattern,
workflow,
session,
availableModels,
toolingSettings,
mcpProbingServerIds,
runtimeTools,
sessionUsage,
sessionActivity,
activeSubagents,
terminalOpen,
terminalRunning,
gitPanelOpen,
gitDirty,
onSend,
onCancelTurn,
onResolveApproval,
@@ -96,6 +121,7 @@ export function ChatPane({
onDismissMcpAuth,
onAuthenticateMcp,
onTerminalToggle,
onGitToggle,
onUpdateSessionModelConfig,
onUpdateSessionTooling,
onUpdateSessionApprovalSettings,
@@ -103,6 +129,8 @@ export function ChatPane({
onPinMessage,
onRegenerateMessage,
onEditAndResendMessage,
onDiscardRunChanges,
onOpenCommitComposer,
branchOriginLabel,
}: ChatPaneProps) {
const [hasComposerContent, setHasComposerContent] = useState(false);
@@ -116,28 +144,140 @@ export function ChatPane({
const composerRef = useRef<MarkdownComposerHandle>(null);
const isSessionBusy = session.status === 'running';
const { visibleMessages, thinkingMessages } = useMemo(() => {
const visible: typeof session.messages = [];
const thinking: typeof session.messages = [];
for (const message of session.messages) {
const displayItems = useMemo(() => {
const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r]));
const items: DisplayItem[] = [];
let pendingThinking: ChatMessageRecord[] = [];
let lastUserMessageId: string | undefined;
const messages = session.messages;
const busy = session.status === 'running';
// Track which runs have been attached to a turn-activity item so we
// can detect orphaned runs that need their own panel.
const consumedRunIds = new Set<string>();
/** Collect unique author names from a batch of thinking messages. */
function collectAgentNames(msgs: ChatMessageRecord[]): Set<string> | undefined {
const names = new Set<string>();
for (const m of msgs) {
if (m.authorName) names.add(m.authorName);
}
return names.size > 0 ? names : undefined;
}
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const isLast = i === messages.length - 1;
if (message.messageKind === 'thinking') {
thinking.push(message);
} else {
visible.push(message);
pendingThinking.push(message);
continue;
}
// Optimistic thinking classification: fold a pending assistant
// message into the current activity group when it immediately follows
// thinking messages during an active turn, OR when it's the very last
// message and no kind has been assigned yet. This prevents the brief
// flash where content renders as a full chat message before the
// message-reclassified event arrives.
if (
busy
&& isLast
&& message.role === 'assistant'
&& message.pending
&& !message.messageKind
) {
pendingThinking.push(message);
continue;
}
if (pendingThinking.length > 0) {
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
if (run) consumedRunIds.add(run.id);
items.push({
type: 'turn-activity',
thinkingMessages: pendingThinking,
run,
turnStartedAt: run?.startedAt,
agentNames: collectAgentNames(pendingThinking),
});
pendingThinking = [];
}
items.push({ type: 'message', message });
if (message.role === 'user') {
lastUserMessageId = message.id;
}
}
return { visibleMessages: visible, thinkingMessages: thinking };
}, [session.messages]);
const lastAssistantIndex = useMemo(() => {
for (let i = visibleMessages.length - 1; i >= 0; i--) {
if (visibleMessages[i].role === 'assistant') return i;
if (pendingThinking.length > 0) {
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
if (run) consumedRunIds.add(run.id);
items.push({
type: 'turn-activity',
thinkingMessages: pendingThinking,
run,
turnStartedAt: run?.startedAt,
agentNames: collectAgentNames(pendingThinking),
});
}
// If the session is busy but no turn-activity was emitted for the
// active run (thinking messages haven't been reclassified yet), inject
// one so the panel appears as soon as the run starts producing events.
if (lastUserMessageId) {
const activeRun = runsByTrigger.get(lastUserMessageId);
if (activeRun && !consumedRunIds.has(activeRun.id)) {
items.push({ type: 'turn-activity', thinkingMessages: [], run: activeRun, turnStartedAt: activeRun.startedAt });
}
}
// Tag the last turn-activity panel for each run so only it shows
// run-level metadata (git summary, discard button, etc.).
const lastPanelIndexByRunId = new Map<string, number>();
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'turn-activity' && item.run) {
lastPanelIndexByRunId.set(item.run.id, i);
}
}
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type === 'turn-activity' && item.run) {
item.isLastRunPanel = lastPanelIndexByRunId.get(item.run.id) === i;
}
}
return items;
}, [session.messages, session.runs, session.status]);
const lastTurnActivityIndex = useMemo(() => {
for (let i = displayItems.length - 1; i >= 0; i--) {
if (displayItems[i].type === 'turn-activity') return i;
}
return -1;
}, [visibleMessages]);
const turnStartedAt = useMemo(() => {
if (session.runs.length === 0) return undefined;
return session.runs[0].startedAt;
}, [session.runs]);
}, [displayItems]);
// A turn activity panel is active when the session is running AND the
// group contains at least one pending message (currently streaming) or
// the associated run is still running.
const isTurnActive = useCallback(
(item: DisplayItem & { type: 'turn-activity' }, itemIndex: number): boolean => {
if (!isSessionBusy) return false;
if (itemIndex !== lastTurnActivityIndex) return false;
const hasStreamingMessage = item.thinkingMessages.some((m) => m.pending);
const runStillRunning = item.run?.status === 'running';
return hasStreamingMessage || !!runStillRunning;
},
[isSessionBusy, lastTurnActivityIndex],
);
const lastAssistantId = useMemo(() => {
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i];
if (m.role === 'assistant' && m.messageKind !== 'thinking') return m.id;
}
return undefined;
}, [session.messages]);
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
@@ -150,8 +290,15 @@ export function ChatPane({
const interactionMode: InteractionMode = session.interactionMode ?? 'interactive';
const isPlanMode = interactionMode === 'plan';
const isScratchpad = isScratchpadProject(project);
const isSingleAgent = pattern.agents.length === 1;
const primaryAgent = pattern.agents[0];
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const isSingleAgent = workflowAgents.length === 1;
const primaryAgent = workflowAgents[0];
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
@@ -159,12 +306,37 @@ export function ChatPane({
const canSubmitInput = hasComposerContent && !isComposerDisabled;
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
const promptFiles = useMemo(() => project.customization?.promptFiles ?? [], [project.customization?.promptFiles]);
const [armedPrompt, setArmedPrompt] = useState<ArmedPrompt | null>(null);
// Map assistant message IDs to the actual model that executed the run, but only
// when it differs from the session's primary agent model (i.e. a prompt override).
const modelOverrideByMessageId = useMemo(() => {
const runsByTrigger = new Map(session.runs.map((r) => [r.triggerMessageId, r]));
const map = new Map<string, string>();
let activeOverrideModel: string | undefined;
for (const message of session.messages) {
if (message.role === 'user') {
const run = runsByTrigger.get(message.id);
const runModel = run?.agents[0]?.model;
if (runModel && runModel !== primaryAgent?.model) {
const resolved = findModel(runModel, availableModels);
activeOverrideModel = resolved?.name ?? runModel;
} else {
activeOverrideModel = undefined;
}
} else if (activeOverrideModel && message.messageKind !== 'thinking') {
map.set(message.id, activeOverrideModel);
}
}
return map;
}, [session.messages, session.runs, primaryAgent?.model, availableModels]);
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
const mcpServers = toolingSettings.mcpServers;
const lspProfiles = toolingSettings.lspProfiles;
const hasConfigurableTools = mcpServers.length > 0 || lspProfiles.length > 0;
const hasToolCallApproval = pattern.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false;
const hasToolCallApproval = workflow.settings.approvalPolicy?.rules.some((r) => r.kind === 'tool-call') ?? false;
const approvalTools = useMemo(
() => listApprovalToolDefinitions(toolingSettings, runtimeTools),
[runtimeTools, toolingSettings],
@@ -174,9 +346,9 @@ export function ChatPane({
() => new Set(
isApprovalOverridden
? session.approvalSettings!.autoApprovedToolNames
: pattern.approvalPolicy?.autoApprovedToolNames ?? [],
: workflow.settings.approvalPolicy?.autoApprovedToolNames ?? [],
),
[isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy],
[isApprovalOverridden, session.approvalSettings, workflow.settings.approvalPolicy],
);
const effectiveAutoApprovedCount = useMemo(() => {
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
@@ -198,13 +370,24 @@ export function ChatPane({
setIsResolvingApproval(false);
setIsUpdatingSessionModelConfig(false);
setEditingMessageId(undefined);
setArmedPrompt(null);
}, [session.id]);
function handleComposerSubmit(content: string) {
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined;
setPendingAttachments([]);
void onSend(content, attachments, messageMode);
if (armedPrompt) {
const invocation = { ...armedPrompt.invocation };
if (content.trim()) {
invocation.resolvedPrompt = `${invocation.resolvedPrompt}\n\n${content.trim()}`;
}
setArmedPrompt(null);
void onSend('', attachments, messageMode, invocation);
} else {
void onSend(content, attachments, messageMode);
}
}
const handleCopyMessage = useCallback((content: string) => {
@@ -296,19 +479,36 @@ export function ChatPane({
<h2 className="font-display truncate text-[13px] font-semibold leading-tight text-[var(--color-text-primary)]">{session.title}</h2>
<p className="truncate text-[11px] leading-tight text-[var(--color-text-muted)]">
{isScratchpad
? `Scratchpad · ${pattern.name}`
: `${project.name} · ${pattern.name} · ${pattern.mode}`}
{!isScratchpad && project.git?.status === 'ready' && (
<span className="ml-2 inline-flex items-center gap-1 text-[var(--color-text-muted)]">
<GitBranch className="inline size-2.5" />
{project.git.branch ?? project.git.head?.shortHash ?? 'HEAD'}
{project.git.isDirty && (
<Circle className="inline size-1.5 fill-amber-500 text-amber-500" />
)}
{(project.git.ahead ?? 0) > 0 && <span>{project.git.ahead}</span>}
{(project.git.behind ?? 0) > 0 && <span>{project.git.behind}</span>}
</span>
)}
? `Scratchpad · ${workflow.name}`
: `${project.name} · ${workflow.name} · ${workflowMode}`}
{!isScratchpad && project.git?.status === 'ready' && (() => {
const git = project.git;
const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD'];
if (git.changes) {
const bd: string[] = [];
if (git.changes.staged > 0) bd.push(`${git.changes.staged} staged`);
if (git.changes.unstaged > 0) bd.push(`${git.changes.unstaged} modified`);
if (git.changes.untracked > 0) bd.push(`${git.changes.untracked} untracked`);
if (bd.length > 0) tipLines.push(bd.join(', '));
}
if (git.ahead || git.behind) {
const sync: string[] = [];
if (git.ahead) sync.push(`${git.ahead} ahead`);
if (git.behind) sync.push(`${git.behind} behind`);
tipLines.push(sync.join(', '));
}
return (
<span className="ml-2 inline-flex items-center gap-1 text-[var(--color-text-muted)]" title={tipLines.join('\n')}>
<GitBranch className="inline size-2.5" />
{git.branch ?? git.head?.shortHash ?? 'HEAD'}
{git.isDirty && (
<Circle className="inline size-1.5 fill-amber-500 text-amber-500" />
)}
{(git.ahead ?? 0) > 0 && <span>{git.ahead}</span>}
{(git.behind ?? 0) > 0 && <span>{git.behind}</span>}
</span>
);
})()}
</p>
</div>
<div className="flex items-center gap-2">
@@ -329,7 +529,15 @@ export function ChatPane({
Awaiting your input
</div>
)}
{isSessionBusy && !pendingApproval && !pendingUserInput && <span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />}
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
const label = summarizeSessionActivity(sessionActivity);
return (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]">
<span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
<span className="transition-opacity duration-200">{label ?? 'Working…'}</span>
</div>
);
})()}
{session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-status-error)]">
<AlertCircle className="size-3.5" />
@@ -355,11 +563,11 @@ export function ChatPane({
{isScratchpad ? (
<>
Scratchpad is ready for ad-hoc questions using{' '}
<span className="text-[var(--color-text-secondary)]">{pattern.name}</span>
<span className="text-[var(--color-text-secondary)]">{workflow.name}</span>
</>
) : (
<>
Using <span className="text-[var(--color-text-secondary)]">{pattern.name}</span> in{' '}
Using <span className="text-[var(--color-text-secondary)]">{workflow.name}</span> in{' '}
<span className="text-[var(--color-text-secondary)]">{project.name}</span>
</>
)}
@@ -375,11 +583,30 @@ export function ChatPane({
/>
)}
<div className="space-y-1">
{visibleMessages.map((message, index) => {
{displayItems.map((item, itemIndex) => {
if (item.type === 'turn-activity') {
return (
<div key={`activity-${item.thinkingMessages[0]?.id ?? item.run?.id ?? itemIndex}`} className="py-2">
<TurnActivityPanel
thinkingMessages={item.thinkingMessages}
run={item.run}
isActive={isTurnActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt}
sessionId={session.id}
agentNames={item.agentNames}
isLastRunPanel={item.isLastRunPanel}
onDiscard={onDiscardRunChanges}
onOpenCommitComposer={onOpenCommitComposer}
/>
</div>
);
}
const message = item.message;
const isUser = message.role === 'user';
const isEditing = editingMessageId === message.id;
const isLastAssistant = index === lastAssistantIndex;
const phase = getAssistantMessagePhase(session, message, index);
const isLastAssistant = message.id === lastAssistantId;
const phase = getAssistantMessagePhase(session, message);
const assistantContainerClass =
phase === 'thinking'
? 'border-[var(--color-accent-sky)]/20 bg-[var(--color-accent-sky)]/5'
@@ -392,20 +619,11 @@ export function ChatPane({
: 'border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]';
const phaseLabel =
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
const modelOverride = !isUser ? modelOverrideByMessageId.get(message.id) : undefined;
const showActions = !isSessionBusy && !message.pending;
const showThinkingBefore = isLastAssistant && thinkingMessages.length > 0;
return (
<div key={message.id}>
{showThinkingBefore && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
)}
<div className="message-enter group py-3" data-message-id={message.id}>
<div className="flex gap-3">
<div
@@ -428,6 +646,11 @@ export function ChatPane({
{phaseLabel}
</span>
)}
{modelOverride && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-purple)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-accent-purple)]">
{modelOverride}
</span>
)}
{showActions && (
<div className="ml-auto">
<MessageActions
@@ -450,6 +673,8 @@ export function ChatPane({
onSave={(content) => handleEditSave(message.id, content)}
onCancel={() => setEditingMessageId(undefined)}
/>
) : isUser && message.promptInvocation ? (
<PromptInvocationChrome invocation={message.promptInvocation} />
) : (
<div
className={
@@ -481,13 +706,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)]" />
)}
@@ -500,15 +719,6 @@ export function ChatPane({
</div>
);
})}
{thinkingMessages.length > 0 && lastAssistantIndex < 0 && (
<div className="py-2">
<ThinkingProcess
messages={thinkingMessages}
isActive={isSessionBusy}
turnStartedAt={turnStartedAt}
/>
</div>
)}
</div>
{activeSubagents && activeSubagents.length > 0 && (
<div className="px-6 py-1">
@@ -705,21 +915,23 @@ export function ChatPane({
onContentChange={setHasComposerContent}
onSubmit={handleComposerSubmit}
placeholder={
pendingApproval
? 'Awaiting approval...'
: pendingUserInput
? 'Awaiting your input above...'
: pendingPlanReview
? 'Review the plan above...'
: pendingMcpAuth
? 'MCP server requires authentication...'
: isSessionBusy
? 'Steer the agent (sends immediately)...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
? 'Describe what to plan...'
: 'Message...'
armedPrompt?.prompt.argumentHint
? armedPrompt.prompt.argumentHint
: pendingApproval
? 'Awaiting approval...'
: pendingUserInput
? 'Awaiting your input above...'
: pendingPlanReview
? 'Review the plan above...'
: pendingMcpAuth
? 'MCP server requires authentication...'
: isSessionBusy
? 'Steer the agent (sends immediately)...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
? 'Describe what to plan...'
: 'Message...'
}
>
{/* Bottom action bar: left = shortcuts, right = buttons */}
@@ -734,10 +946,20 @@ export function ChatPane({
onToggle={onTerminalToggle}
/>
)}
{onGitToggle && !isScratchpad && (
<InlineGitPill
isDirty={!!gitDirty}
isOpen={!!gitPanelOpen}
onToggle={onGitToggle}
/>
)}
{!isScratchpad && promptFiles.length > 0 && (
<InlinePromptPill
armedPrompt={armedPrompt}
disabled={isComposerDisabled}
onSubmit={(content) => void onSend(content)}
onArm={setArmedPrompt}
onDisarm={() => setArmedPrompt(null)}
onSubmit={(promptInvocation) => void onSend('', undefined, undefined, promptInvocation)}
promptFiles={promptFiles}
/>
)}
@@ -798,9 +1020,9 @@ export function ChatPane({
{/* Send / Stop / Steer button */}
<button
className={`flex size-8 items-center justify-center rounded-lg transition-all duration-150 ${
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt
? 'bg-[var(--color-status-error)]/80 text-white hover:bg-[var(--color-status-error)]'
: canSubmitInput || pendingAttachments.length > 0
: canSubmitInput || pendingAttachments.length > 0 || armedPrompt
? isSessionBusy
? 'bg-[var(--color-status-warning)] text-white hover:brightness-110'
: isPlanMode
@@ -808,9 +1030,9 @@ export function ChatPane({
: 'brand-gradient-bg text-white shadow-[0_2px_12px_rgba(36,92,249,0.25)] hover:shadow-[0_4px_20px_rgba(36,92,249,0.35)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)]'
}`}
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0 && !armedPrompt}
onClick={() => {
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt) {
onCancelTurn?.();
} else {
composerRef.current?.submit();
@@ -818,7 +1040,7 @@ export function ChatPane({
}}
type="button"
aria-label={
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 && !armedPrompt
? 'Stop generating'
: isSessionBusy
? 'Steer agent'
@@ -882,6 +1104,79 @@ export function ChatPane({
);
}
/* ── Prompt invocation chrome ───────────────────────────────── */
function formatInvocationSourcePath(sourcePath: string): string {
const normalized = sourcePath.replace(/\\/g, '/');
const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//;
if (ancestorPrefix.test(normalized)) {
const segments = normalized.split('/').filter((s) => s !== '..');
return `${segments.join('/')}`;
}
return normalized;
}
function PromptInvocationChrome({ invocation }: { invocation: ProjectPromptInvocation }) {
const [expanded, setExpanded] = useState(false);
const isAncestor = invocation.sourcePath.startsWith('..');
return (
<div className="rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3">
<button
className="flex w-full items-center gap-2.5 text-left"
onClick={() => setExpanded(!expanded)}
type="button"
aria-expanded={expanded}
>
<FileText className="size-3.5 shrink-0 text-[var(--color-status-success)]" />
<span className="flex-1 text-[13px] font-medium text-[var(--color-text-primary)]">
{invocation.name}
</span>
<div className="flex items-center gap-1.5">
{invocation.model && (
<span className="rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{invocation.model}
</span>
)}
{invocation.agent && (
<span className="rounded bg-[var(--color-accent-sky)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{invocation.agent}
</span>
)}
{invocation.tools && invocation.tools.length > 0 && (
<span className="rounded bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
{invocation.tools.length} tool{invocation.tools.length === 1 ? '' : 's'}
</span>
)}
{expanded
? <ChevronDown className="size-3.5 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3.5 text-[var(--color-text-muted)]" />}
</div>
</button>
{invocation.description && (
<p className="mt-1 pl-6 text-[11px] text-[var(--color-text-muted)]">{invocation.description}</p>
)}
<div className="mt-0.5 flex items-center gap-1.5 pl-6">
{isAncestor && (
<span className="rounded bg-[var(--color-surface-3)] px-1 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
<span className="text-[10px] text-[var(--color-text-muted)]" title={invocation.sourcePath}>
{formatInvocationSourcePath(invocation.sourcePath)}
</span>
</div>
{expanded && (
<div className="mt-2.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
<div className="max-h-48 overflow-y-auto text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
<MarkdownContent content={invocation.resolvedPrompt} />
</div>
</div>
)}
</div>
);
}
/* ── Branch origin banner ───────────────────────────────────── */
function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) {
+10 -2
View File
@@ -16,6 +16,7 @@ import {
Loader2,
} from 'lucide-react';
import { CliInstallGuide } from '@renderer/components/settings/CliInstallGuide';
import type {
SidecarConnectionDiagnostics,
SidecarConnectionStatus,
@@ -391,8 +392,15 @@ export function CopilotStatusCard({
</div>
)}
{/* Action hint for non-ready states */}
{!isHealthy && config.actionLabel && (
{/* Installation guide for missing CLI */}
{connection.status === 'copilot-cli-missing' && (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
<CliInstallGuide isRefreshing={isRefreshing} onRefresh={onRefresh} />
</div>
)}
{/* Action hint for other non-ready states */}
{!isHealthy && connection.status !== 'copilot-cli-missing' && config.actionLabel && (
<div className="flex items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)] px-3 py-2.5">
<div className="mt-0.5 shrink-0">{config.actionIcon}</div>
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">{config.actionLabel}</p>
+616
View File
@@ -0,0 +1,616 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AlertTriangle,
ArrowDownToLine,
ArrowUpFromLine,
Check,
ChevronDown,
ChevronRight,
Cloud,
FileCode2,
FileMinus2,
FilePlus2,
GitBranch,
GitCommitHorizontal,
History,
Loader2,
Plus,
RefreshCw,
Trash2,
X,
} from 'lucide-react';
import { getElectronApi } from '@renderer/lib/electronApi';
import type {
ProjectGitBranchSummary,
ProjectGitCommitLogEntry,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitWorkingTreeFile,
} from '@shared/domain/project';
/* ── Helpers ───────────────────────────────────────────────── */
function fileBaseName(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
}
function fileDir(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days === 1) return 'yesterday';
if (days < 7) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
/* ── Section header ────────────────────────────────────────── */
function SectionHeader({
icon,
label,
count,
expanded,
onToggle,
}: {
icon: React.ReactNode;
label: string;
count?: number;
expanded: boolean;
onToggle: () => void;
}) {
return (
<button
className="flex w-full items-center gap-2 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors duration-100 hover:bg-[var(--color-surface-2)]/30"
onClick={onToggle}
type="button"
aria-expanded={expanded}
>
{expanded
? <ChevronDown className="size-2.5 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-2.5 text-[var(--color-text-muted)]" />}
{icon}
<span className="font-display text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{label}
</span>
{count !== undefined && count > 0 && (
<span className="rounded-full bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-[var(--color-text-muted)]">
{count}
</span>
)}
</button>
);
}
/* ── File status icon ──────────────────────────────────────── */
function fileIcon(file: ProjectGitWorkingTreeFile) {
if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') {
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
}
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
}
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
/* ── Changed file row ──────────────────────────────────────── */
function ChangedFileEntry({
file,
projectId,
}: {
file: ProjectGitWorkingTreeFile;
projectId: string;
}) {
const api = getElectronApi();
const [expanded, setExpanded] = useState(false);
const [preview, setPreview] = useState<ProjectGitDiffPreview>();
const dir = fileDir(file.path);
const base = fileBaseName(file.path);
const status = file.stagedStatus ?? file.unstagedStatus ?? 'modified';
const handleToggle = useCallback(async () => {
if (expanded) {
setExpanded(false);
return;
}
setExpanded(true);
if (!preview) {
try {
const result = await api.getProjectGitFilePreview({
projectId,
file: { path: file.path, previousPath: file.previousPath },
});
if (result) setPreview(result);
} catch {
// Preview is best-effort
}
}
}, [api, expanded, file.path, file.previousPath, preview, projectId]);
return (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<button
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-left text-[10px] transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
onClick={() => void handleToggle()}
type="button"
aria-expanded={expanded}
>
<ChevronRight
className={`size-2 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
{fileIcon(file)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{status}
</span>
</button>
{expanded && preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-40 overflow-auto bg-[var(--color-surface-0)] px-3 py-1 font-mono text-[9px] leading-relaxed">
{preview.diff
? preview.diff.split('\n').map((line, i) => {
let cls = 'text-[var(--color-text-secondary)]';
if (line.startsWith('+') && !line.startsWith('+++')) cls = 'text-[var(--color-status-success)]';
else if (line.startsWith('-') && !line.startsWith('---')) cls = 'text-[var(--color-status-error)]';
else if (line.startsWith('@@')) cls = 'text-[var(--color-accent-sky)]';
return <div key={i} className={cls}>{line || '\u00A0'}</div>;
})
: preview.newFileContents
? preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: <div className="text-[var(--color-text-muted)] italic">Binary file</div>}
</pre>
</div>
)}
</div>
);
}
/* ── Branch row ────────────────────────────────────────────── */
function BranchRow({
branch,
onSwitch,
onDelete,
isSwitching,
}: {
branch: ProjectGitBranchSummary;
onSwitch: () => void;
onDelete: () => void;
isSwitching: boolean;
}) {
return (
<div className="flex items-center gap-1.5 px-3 py-[5px] text-[10px]">
<GitBranch className={`size-3 shrink-0 ${branch.isCurrent ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)]'}`} />
<span className={`min-w-0 flex-1 truncate font-mono ${branch.isCurrent ? 'font-medium text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
{branch.name}
</span>
{branch.isCurrent && (
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]">
current
</span>
)}
{!branch.isCurrent && (
<>
<button
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onSwitch}
type="button"
title={`Switch to ${branch.name}`}
disabled={isSwitching}
>
{isSwitching ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
</button>
<button
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={onDelete}
type="button"
title={`Delete ${branch.name}`}
>
<Trash2 className="size-2.5" />
</button>
</>
)}
</div>
);
}
/* ── Commit log row ────────────────────────────────────────── */
function CommitRow({ commit }: { commit: ProjectGitCommitLogEntry }) {
return (
<div className="flex items-start gap-2 px-3 py-[5px] text-[10px]">
<GitCommitHorizontal className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<p className="truncate text-[var(--color-text-secondary)]">
{commit.subject}
</p>
<p className="flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
<span className="font-mono">{commit.shortHash}</span>
<span>·</span>
<span>{commit.authorName}</span>
<span>·</span>
<span>{relativeTime(commit.committedAt)}</span>
</p>
</div>
</div>
);
}
/* ── Create branch dialog ──────────────────────────────────── */
function CreateBranchForm({
onSubmit,
onCancel,
}: {
onSubmit: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
return (
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5">
<input
autoFocus
className="min-w-0 flex-1 rounded border border-[var(--color-border)] bg-[var(--color-surface-0)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault();
onSubmit(name.trim());
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}}
placeholder="Branch name…"
value={name}
/>
<button
className="rounded p-1 text-[var(--color-status-success)] transition-colors duration-100 hover:bg-[var(--color-status-success)]/10 disabled:opacity-40"
disabled={!name.trim()}
onClick={() => onSubmit(name.trim())}
type="button"
>
<Check className="size-3" />
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={onCancel}
type="button"
>
<X className="size-3" />
</button>
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface GitPanelProps {
projectId: string;
onDirtyChange?: (isDirty: boolean) => void;
}
export function GitPanel({ projectId, onDirtyChange }: GitPanelProps) {
const api = getElectronApi();
const [details, setDetails] = useState<ProjectGitDetails>();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showChanges, setShowChanges] = useState(true);
const [showBranches, setShowBranches] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showCreateBranch, setShowCreateBranch] = useState(false);
const [switchingBranch, setSwitchingBranch] = useState<string>();
const [operationError, setOperationError] = useState<string>();
const [networkBusy, setNetworkBusy] = useState<'push' | 'pull' | 'fetch'>();
const loadDetails = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
else setRefreshing(true);
try {
const result = await api.getProjectGitDetails({ projectId });
setDetails(result);
setOperationError(undefined);
onDirtyChange?.(result.context.isDirty ?? false);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setLoading(false);
setRefreshing(false);
}
}, [api, projectId]);
useEffect(() => {
void loadDetails();
}, [loadDetails]);
const ctx = details?.context;
const workingTree = details?.workingTree;
const branches = details?.branches ?? [];
const commits = details?.recentCommits ?? [];
const changedFiles = workingTree?.files ?? [];
const handlePush = useCallback(async () => {
setNetworkBusy('push');
setOperationError(undefined);
try {
await api.pushProjectGit({ projectId });
await loadDetails(true);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setNetworkBusy(undefined);
}
}, [api, loadDetails, projectId]);
const handlePull = useCallback(async () => {
setNetworkBusy('pull');
setOperationError(undefined);
try {
await api.pullProjectGit({ projectId });
await loadDetails(true);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setNetworkBusy(undefined);
}
}, [api, loadDetails, projectId]);
const handleFetch = useCallback(async () => {
setNetworkBusy('fetch');
setOperationError(undefined);
try {
await api.fetchProjectGit({ projectId });
await loadDetails(true);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setNetworkBusy(undefined);
}
}, [api, loadDetails, projectId]);
const handleSwitchBranch = useCallback(async (name: string) => {
setSwitchingBranch(name);
setOperationError(undefined);
try {
await api.switchProjectGitBranch({ projectId, name });
await loadDetails(true);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
} finally {
setSwitchingBranch(undefined);
}
}, [api, loadDetails, projectId]);
const handleDeleteBranch = useCallback(async (name: string) => {
setOperationError(undefined);
try {
await api.deleteProjectGitBranch({ projectId, name });
await loadDetails(true);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
}
}, [api, loadDetails, projectId]);
const handleCreateBranch = useCallback(async (name: string) => {
setShowCreateBranch(false);
setOperationError(undefined);
try {
await api.createProjectGitBranch({ projectId, name, checkout: true });
await loadDetails(true);
} catch (error) {
setOperationError(error instanceof Error ? error.message : String(error));
}
}, [api, loadDetails, projectId]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-4 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git status" />
</div>
);
}
if (!ctx || ctx.status !== 'ready') {
return (
<div className="px-3 py-4 text-center text-[11px] text-[var(--color-text-muted)]">
{ctx?.status === 'not-repository'
? 'Not a git repository'
: ctx?.status === 'git-missing'
? 'Git is not installed'
: ctx?.errorMessage ?? 'Unable to read git status'}
</div>
);
}
return (
<div className="flex flex-col">
{/* Branch header + network actions */}
<div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
<div className="flex items-center gap-1.5">
<GitBranch className="size-3 text-[var(--color-accent-sky)]" />
<span className="font-mono text-[11px] font-medium text-[var(--color-text-primary)]">
{ctx.branch ?? 'detached HEAD'}
</span>
{ctx.upstream && (
<span className="text-[9px] text-[var(--color-text-muted)]">
{ctx.ahead !== undefined && ctx.ahead > 0 && <span className="text-[var(--color-status-success)]">{ctx.ahead}</span>}
{ctx.behind !== undefined && ctx.behind > 0 && <span className="ml-0.5 text-[var(--color-status-warning)]">{ctx.behind}</span>}
</span>
)}
<div className="flex-1" />
{/* Network buttons */}
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
disabled={!!networkBusy}
onClick={() => void handleFetch()}
title="Fetch"
type="button"
>
{networkBusy === 'fetch' ? <Loader2 className="size-3 animate-spin" /> : <Cloud className="size-3" />}
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
disabled={!!networkBusy}
onClick={() => void handlePull()}
title="Pull"
type="button"
>
{networkBusy === 'pull' ? <Loader2 className="size-3 animate-spin" /> : <ArrowDownToLine className="size-3" />}
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
disabled={!!networkBusy}
onClick={() => void handlePush()}
title="Push"
type="button"
>
{networkBusy === 'push' ? <Loader2 className="size-3 animate-spin" /> : <ArrowUpFromLine className="size-3" />}
</button>
<button
className="rounded p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={() => void loadDetails(true)}
title="Refresh"
type="button"
>
<RefreshCw className={`size-3 ${refreshing ? 'animate-spin' : ''}`} />
</button>
</div>
{ctx.isDirty && (
<div className="mt-1 text-[9px] text-[var(--color-status-warning)]">
{ctx.changedFileCount} uncommitted {ctx.changedFileCount === 1 ? 'change' : 'changes'}
</div>
)}
</div>
{/* Error */}
{operationError && (
<div className="flex items-start gap-1.5 border-b border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-3 py-1.5 text-[9px] text-[var(--color-status-error)]" role="alert">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 flex-1">{operationError}</span>
<button
className="shrink-0 rounded p-0.5 transition-colors duration-100 hover:bg-[var(--color-status-error)]/10"
onClick={() => setOperationError(undefined)}
type="button"
aria-label="Dismiss error"
>
<X className="size-2.5" />
</button>
</div>
)}
{/* Changed files */}
<SectionHeader
count={changedFiles.length}
expanded={showChanges}
icon={<FileCode2 className="size-3 text-[var(--color-accent-sky)]" />}
label="Changes"
onToggle={() => setShowChanges(!showChanges)}
/>
{showChanges && (
<div>
{changedFiles.length === 0 ? (
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">Working tree clean</p>
) : (
changedFiles.map((file) => (
<ChangedFileEntry
file={file}
key={file.path}
projectId={projectId}
/>
))
)}
</div>
)}
{/* Branches */}
<SectionHeader
count={branches.length}
expanded={showBranches}
icon={<GitBranch className="size-3 text-[var(--color-status-success)]" />}
label="Branches"
onToggle={() => setShowBranches(!showBranches)}
/>
{showBranches && (
<div>
{/* New branch button */}
{!showCreateBranch && (
<button
className="flex w-full items-center gap-1.5 px-3 py-[5px] text-[10px] text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-2)]/40 hover:text-[var(--color-text-accent)]"
onClick={() => setShowCreateBranch(true)}
type="button"
>
<Plus className="size-3" />
New branch
</button>
)}
{showCreateBranch && (
<CreateBranchForm
onCancel={() => setShowCreateBranch(false)}
onSubmit={(name) => void handleCreateBranch(name)}
/>
)}
{branches.map((branch) => (
<BranchRow
branch={branch}
isSwitching={switchingBranch === branch.name}
key={branch.name}
onDelete={() => void handleDeleteBranch(branch.name)}
onSwitch={() => void handleSwitchBranch(branch.name)}
/>
))}
</div>
)}
{/* Commit history */}
<SectionHeader
count={commits.length}
expanded={showHistory}
icon={<History className="size-3 text-[var(--color-text-muted)]" />}
label="Recent Commits"
onToggle={() => setShowHistory(!showHistory)}
/>
{showHistory && (
<div>
{commits.length === 0 ? (
<p className="px-3 py-2 text-[10px] text-[var(--color-text-muted)]">No commit history</p>
) : (
commits.map((commit) => (
<CommitRow commit={commit} key={commit.hash} />
))
)}
</div>
)}
</div>
);
}
-157
View File
@@ -1,157 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Star, X } from 'lucide-react';
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
interface NewSessionModalProps {
projects: ProjectRecord[];
patterns: PatternDefinition[];
defaultProjectId?: string;
onClose: () => void;
onCreate: (projectId: string, patternId: string) => void;
onTogglePatternFavorite?: (patternId: string, isFavorite: boolean) => void;
}
export function NewSessionModal({
projects,
patterns,
defaultProjectId,
onClose,
onCreate,
onTogglePatternFavorite,
}: NewSessionModalProps) {
const userProjects = useMemo(
() => projects.filter((p) => !isScratchpadProject(p)),
[projects],
);
const [projectId, setProjectId] = useState(defaultProjectId ?? userProjects[0]?.id ?? '');
const availablePatterns = useMemo(
() =>
patterns
.filter((pattern) => pattern.availability !== 'unavailable')
.sort((a, b) => {
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
return 0;
}),
[patterns],
);
const [patternId, setPatternId] = useState(availablePatterns[0]?.id ?? '');
useEffect(() => {
if (!availablePatterns.some((pattern) => pattern.id === patternId)) {
setPatternId(availablePatterns[0]?.id ?? '');
}
}, [availablePatterns, patternId]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const canCreate = projectId && patternId;
return (
<div className="overlay-backdrop-enter fixed inset-0 z-50 flex items-center justify-center bg-[#07080e]/90 backdrop-blur-sm" role="dialog" aria-modal="true" aria-labelledby="new-session-title">
<div className="overlay-panel-enter w-full max-w-md rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_16px_64px_rgba(0,0,0,0.5)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-4">
<h2 id="new-session-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">New Session</h2>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
{/* Body */}
<div className="space-y-4 px-5 py-5">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Project</span>
<select
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => setProjectId(e.target.value)}
value={projectId}
>
{userProjects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Pattern</span>
<div className="space-y-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-0)] p-1.5">
{availablePatterns.map((p) => (
<div
key={p.id}
className={`flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition ${
patternId === p.id
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)] ring-1 ring-[var(--color-border-glow)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-glass-hover)]'
}`}
onClick={() => setPatternId(p.id)}
>
<span className="flex-1 truncate">
{p.name}
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">({p.mode})</span>
</span>
{onTogglePatternFavorite && (
<button
className={`shrink-0 transition ${
p.isFavorite
? 'text-[var(--color-status-warning)] hover:text-[var(--color-status-warning)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={(e) => {
e.stopPropagation();
onTogglePatternFavorite(p.id, !p.isFavorite);
}}
title={p.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
type="button"
>
<Star className={`size-3.5 ${p.isFavorite ? 'fill-current' : ''}`} />
</button>
)}
</div>
))}
</div>
{patternId && (
<p className="text-[12px] text-[var(--color-text-muted)]">
{availablePatterns.find((p) => p.id === patternId)?.description}
</p>
)}
</label>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-[var(--color-border)] px-5 py-3">
<button
className="rounded-lg px-4 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-[var(--color-accent-sky)] disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canCreate}
onClick={() => canCreate && onCreate(projectId, patternId)}
type="button"
>
Start Session
</button>
</div>
</div>
</div>
);
}
-663
View File
@@ -1,663 +0,0 @@
import { useState } from 'react';
import {
AlertCircle,
ArrowLeftRight,
CheckCircle,
ChevronLeft,
GitFork,
ListOrdered,
Lock,
MessageSquare,
Plus,
ShieldCheck,
Trash2,
Users,
type LucideIcon,
} from 'lucide-react';
import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval';
import type { ModelDefinition } from '@shared/domain/models';
import {
addAgentToGraph,
removeAgentFromGraph,
resolvePatternGraph,
syncPatternGraph,
validatePatternDefinition,
type OrchestrationMode,
type PatternDefinition,
type PatternAgentDefinition,
type PatternGraph,
} from '@shared/domain/pattern';
import {
listApprovalToolDefinitions,
type ApprovalToolDefinition,
type ApprovalToolKind,
type RuntimeToolDefinition,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
import { ToggleSwitch } from '@renderer/components/ui';
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
interface PatternEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
pattern: PatternDefinition;
isBuiltin: boolean;
toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onChange: (pattern: PatternDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
}
interface ModeInfo {
icon: LucideIcon;
label: string;
description: string;
}
const modeInfo: Record<OrchestrationMode, ModeInfo> = {
single: {
icon: MessageSquare,
label: 'Single',
description: 'Direct conversation with one agent',
},
sequential: {
icon: ListOrdered,
label: 'Sequential',
description: 'Agents process in order, each refining the result',
},
concurrent: {
icon: GitFork,
label: 'Concurrent',
description: 'Multiple agents respond in parallel',
},
handoff: {
icon: ArrowLeftRight,
label: 'Handoff',
description: 'Triage agent routes to specialists',
},
'group-chat': {
icon: Users,
label: 'Group Chat',
description: 'Agents iterate in managed round-robin',
},
magentic: {
icon: Lock,
label: 'Magentic',
description: 'Not yet available in .NET',
},
};
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const baseClasses =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${baseClasses} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={baseClasses}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
export function PatternEditor({
availableModels,
pattern,
isBuiltin,
toolingSettings,
runtimeTools,
onChange,
onDelete,
onSave,
onBack,
}: PatternEditorProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const issues = validatePatternDefinition(pattern);
const graph = resolvePatternGraph(pattern);
function emitChange(nextPattern: PatternDefinition) {
onChange({ ...nextPattern, graph: resolvePatternGraph(nextPattern) });
}
function emitModeChange(nextPattern: PatternDefinition) {
onChange(syncPatternGraph(nextPattern));
}
function emitGraphChange(nextGraph: PatternGraph) {
onChange({ ...pattern, graph: nextGraph });
}
function addAgent() {
const newAgent: PatternAgentDefinition = {
id: `agent-${crypto.randomUUID()}`,
name: `Agent ${pattern.agents.length + 1}`,
description: '',
instructions: '',
model: 'gpt-5.4',
reasoningEffort: 'high',
};
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
}
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
emitChange({
...pattern,
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
});
}
function removeAgent(agentId: string) {
if (pattern.agents.length <= 1) {
return;
}
const updatedGraph = removeAgentFromGraph(graph, pattern.mode, agentId);
onChange({
...pattern,
agents: pattern.agents.filter((a) => a.id !== agentId),
graph: updatedGraph,
});
setSelectedNodeId(null);
}
function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) {
emitChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) });
}
function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean {
return pattern.approvalPolicy?.rules.some((r) => r.kind === kind) ?? false;
}
function checkpointAgentIds(kind: ApprovalCheckpointKind): string[] | undefined {
return pattern.approvalPolicy?.rules.find((r) => r.kind === kind)?.agentIds;
}
function toggleCheckpoint(kind: ApprovalCheckpointKind, enabled: boolean) {
updateApprovalPolicy((current) => {
const otherRules = (current?.rules ?? []).filter((r) => r.kind !== kind);
if (!enabled) {
return { rules: otherRules };
}
return { rules: [...otherRules, { kind }] };
});
}
function setCheckpointAgentScope(kind: ApprovalCheckpointKind, agentIds: string[] | undefined) {
updateApprovalPolicy((current) => {
const rules = (current?.rules ?? []).map((r) =>
r.kind === kind ? { ...r, agentIds } : r,
);
return { rules };
});
}
const approvalTools = listApprovalToolDefinitions(toolingSettings, runtimeTools);
const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
function toggleToolAutoApproval(toolId: string) {
const current = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []);
if (current.has(toolId)) {
current.delete(toolId);
} else {
current.add(toolId);
}
updateApprovalPolicy((policy) => ({
rules: policy?.rules ?? [],
autoApprovedToolNames: current.size > 0 ? [...current] : undefined,
}));
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
<div className="flex items-center gap-3">
<button
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
{pattern.name || 'Untitled pattern'}
</h3>
<p className="text-[12px] text-[var(--color-text-muted)]">
{isBuiltin ? 'Built-in pattern' : 'Custom pattern'}
</p>
</div>
</div>
<div className="no-drag flex items-center gap-2">
{!isBuiltin && onDelete && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
onClick={onDelete}
type="button"
>
<Trash2 className="size-3.5" />
Delete
</button>
)}
<button
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
onClick={onSave}
type="button"
>
Save
</button>
</div>
</div>
{/* Body — graph canvas + inspector split */}
<div className="flex min-h-0 flex-1">
{/* Left column: graph canvas + settings below */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Validation banner */}
<div className="px-5 pt-4">
{issues.length > 0 ? (
<div className="space-y-1.5">
{issues.map((issue, i) => (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
issue.level === 'error'
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
}`}
key={`${issue.field ?? 'v'}-${i}`}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
{issue.message}
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
<CheckCircle className="size-3.5" />
Pattern is valid
</div>
)}
</div>
{/* Graph canvas */}
<div className="flex items-center justify-between px-5 pt-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Topology
</h4>
<button
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={addAgent}
type="button"
>
<Plus className="size-3" />
Add agent
</button>
</div>
<div className="min-h-[300px] flex-1 px-5 py-3">
<PatternGraphCanvas
pattern={pattern}
availableModels={availableModels}
onGraphChange={emitGraphChange}
onAgentRemove={removeAgent}
onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId}
/>
</div>
{/* Scrollable settings below graph */}
<div className="max-h-[45%] overflow-y-auto border-t border-[var(--color-border-subtle)] px-5 py-5">
<div className="space-y-8">
{/* General */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
General
</h4>
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Name"
onChange={(v) => emitChange({ ...pattern, name: v })}
placeholder="Pattern name"
value={pattern.name}
/>
<InputField
label="Description"
onChange={(v) => emitChange({ ...pattern, description: v })}
placeholder="What this pattern does..."
value={pattern.description}
/>
</div>
</section>
{/* Mode selector */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Orchestration Mode
</h4>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => {
const info = modeInfo[mode];
const Icon = info.icon;
const selected = pattern.mode === mode;
const disabled = mode === 'magentic';
return (
<button
className={`flex flex-col rounded-xl border p-2.5 text-left transition-all duration-200 ${
selected
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
: disabled
? 'cursor-not-allowed border-[var(--color-border-subtle)] opacity-40'
: 'border-[var(--color-border)] hover:border-[var(--color-border)] hover:bg-[var(--color-glass)]'
}`}
disabled={disabled}
key={mode}
onClick={() => emitModeChange({ ...pattern, mode })}
type="button"
>
<div className="flex items-center gap-1.5">
<Icon
className={`size-3.5 ${selected ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-muted)]'}`}
/>
<span
className={`text-[11px] font-semibold ${
selected ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-secondary)]'
}`}
>
{info.label}
</span>
</div>
<p className="mt-1 text-[10px] leading-snug text-[var(--color-text-muted)]">
{info.description}
</p>
</button>
);
})}
</div>
</section>
{/* Approval checkpoints */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Approval Checkpoints
</h4>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
</p>
<div className="space-y-3">
<ApprovalCheckpointRow
agents={pattern.agents}
enabled={isCheckpointEnabled('tool-call')}
kind="tool-call"
label="Tool call approval"
description="Require approval before the agent executes tool calls"
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
scopedAgentIds={checkpointAgentIds('tool-call')}
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Auto-approved tools
</div>
<p className="mb-3 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Tools marked as auto-approved will skip manual review.
Sessions can override these defaults from the Activity panel.
</p>
{approvalTools.length === 0 ? (
<p className="py-2 text-center text-[11px] text-[var(--color-text-muted)]">
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
</p>
) : (
<ToolApprovalGroupedList
autoApprovedSet={autoApprovedSet}
onToggle={toggleToolAutoApproval}
tools={approvalTools}
/>
)}
</ApprovalCheckpointRow>
<ApprovalCheckpointRow
agents={pattern.agents}
enabled={isCheckpointEnabled('final-response')}
kind="final-response"
label="Final response review"
description="Review and approve assistant messages before publication"
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
scopedAgentIds={checkpointAgentIds('final-response')}
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
/>
</div>
</section>
</div>
</div>
</div>
{/* Right column: node inspector */}
<div className="w-[320px] shrink-0 overflow-y-auto border-l border-[var(--color-border-subtle)] bg-[var(--color-glass)]">
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Inspector
</h4>
</div>
<PatternGraphInspector
availableModels={availableModels}
agents={pattern.agents}
graph={graph}
mode={pattern.mode}
selectedNodeId={selectedNodeId}
onAgentChange={updateAgent}
onAgentRemove={removeAgent}
onGraphChange={emitGraphChange}
/>
</div>
</div>
</div>
);
}
/* ── Approval checkpoint row ───────────────────────────────── */
function ApprovalCheckpointRow({
agents,
children,
enabled,
kind: _kind,
label,
description,
onToggle,
scopedAgentIds,
onScopeChange,
}: {
agents: PatternAgentDefinition[];
children?: React.ReactNode;
enabled: boolean;
kind: ApprovalCheckpointKind;
label: string;
description: string;
onToggle: (enabled: boolean) => void;
scopedAgentIds: string[] | undefined;
onScopeChange: (agentIds: string[] | undefined) => void;
}){
const isAllAgents = !scopedAgentIds || scopedAgentIds.length === 0;
function toggleAgentScope(agentId: string) {
const current = scopedAgentIds ?? [];
const next = current.includes(agentId)
? current.filter((id) => id !== agentId)
: [...current, agentId];
onScopeChange(next.length > 0 ? next : undefined);
}
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] p-4">
<button className="flex w-full items-center gap-3 text-left" onClick={() => onToggle(!enabled)} type="button">
<ShieldCheck className={`size-4 shrink-0 ${enabled ? 'text-[var(--color-text-accent)]' : 'text-[var(--color-text-muted)]'}`} />
<div className="min-w-0 flex-1">
<span className="text-[12px] font-medium text-[var(--color-text-primary)]">{label}</span>
<p className="text-[11px] text-[var(--color-text-muted)]">{description}</p>
</div>
<ToggleSwitch enabled={enabled} />
</button>
{/* Agent scope selector */}
{enabled && agents.length > 1 && (
<div className="mt-3 border-t border-[var(--color-border-subtle)] pt-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">Scope</span>
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition-all duration-200 ${
isAllAgents
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onScopeChange(undefined)}
type="button"
>
All agents
</button>
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition-all duration-200 ${
!isAllAgents
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => onScopeChange([])}
type="button"
>
Selected agents
</button>
</div>
{!isAllAgents && (
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => {
const isSelected = scopedAgentIds?.includes(agent.id) ?? false;
return (
<button
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] ring-1 ring-[var(--color-border-glow)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
key={agent.id}
onClick={() => toggleAgentScope(agent.id)}
type="button"
>
{agent.name || 'Unnamed'}
</button>
);
})}
</div>
)}
</div>
)}
{/* Optional additional content (e.g. tool auto-approval list) */}
{enabled && children && (
<div className="mt-3 border-t border-[var(--color-border-subtle)] pt-3">
{children}
</div>
)}
</div>
);
}
/* ── Tool auto-approval grouped list ───────────────────────── */
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
const approvalKindLabels: Record<ApprovalToolKind, string> = {
builtin: 'Built-in',
mcp: 'MCP Servers',
lsp: 'Language Servers',
mixed: 'Other',
};
function ToolApprovalGroupedList({
tools,
autoApprovedSet,
onToggle,
}: {
tools: ApprovalToolDefinition[];
autoApprovedSet: Set<string>;
onToggle: (toolId: string) => void;
}) {
const groups = approvalKindOrder
.map((kind) => ({ kind, tools: tools.filter((t) => t.kind === kind) }))
.filter((g) => g.tools.length > 0);
const showHeaders = groups.length > 1;
return (
<div>
{groups.map((group, i) => (
<div key={group.kind}>
{showHeaders && (
<div className={`text-[9px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)] ${i > 0 ? 'mt-3' : ''} mb-1`}>
{approvalKindLabels[group.kind]}
</div>
)}
{group.tools.map((tool) => (
<ToolApprovalToggleRow
enabled={autoApprovedSet.has(tool.id)}
key={tool.id}
onToggle={() => onToggle(tool.id)}
tool={tool}
/>
))}
</div>
))}
</div>
);
}
function ToolApprovalToggleRow({
tool,
enabled,
onToggle,
}: {
tool: ApprovalToolDefinition;
enabled: boolean;
onToggle: () => void;
}) {
const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined);
return (
<button
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]/60"
onClick={onToggle}
type="button"
>
<div className="min-w-0 flex-1">
<span className="truncate text-[12px] font-medium text-[var(--color-text-secondary)]">{tool.label}</span>
{detail && <div className="truncate text-[10px] text-[var(--color-text-muted)]">{detail}</div>}
</div>
<ToggleSwitch enabled={enabled} />
</button>
);
}
@@ -5,10 +5,17 @@ import { ToggleSwitch } from '@renderer/components/ui';
import type { ProjectRecord, ProjectGitContext } from '@shared/domain/project';
import type { DiscoveredMcpServer } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { ProjectAgentProfile, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization';
import type { ProjectAgentProfile, ProjectInstructionApplicationMode, ProjectInstructionFile, ProjectPromptFile } from '@shared/domain/projectCustomization';
/* ── Types ────────────────────────────────────────────────── */
/** Shortens ancestor-relative source paths like `..\..\..\.github\prompts\x.md` to `↑ .github/prompts/x.md` */
function formatSettingsSourcePath(sourcePath: string): string {
const normalized = sourcePath.replace(/\\/g, '/');
const segments = normalized.split('/').filter((s) => s !== '..');
return `${segments.join('/')}`;
}
type ProjectSettingsSection = 'overview' | 'instructions' | 'agents' | 'prompts' | 'mcp-servers' | 'danger-zone';
interface NavItem {
@@ -335,7 +342,7 @@ function InstructionsContent({
return (
<div>
<SectionHeader
description="Repository instructions automatically included in every session. Discovered from .github/copilot-instructions.md and AGENTS.md."
description="Repository instructions discovered from copilot-instructions.md, AGENTS.md, CLAUDE.md, .github/instructions/**/*.instructions.md, and .claude/rules/**/*.md."
title="Instructions"
>
<RescanButton onClick={onRescan} />
@@ -343,7 +350,7 @@ function InstructionsContent({
{instructions.length === 0 ? (
<EmptyState>
No instruction files found. Add a <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code> or <code className="text-[var(--color-text-secondary)]">AGENTS.md</code> file to your project root.
No instruction files found. Add <code className="text-[var(--color-text-secondary)]">.github/copilot-instructions.md</code>, <code className="text-[var(--color-text-secondary)]">AGENTS.md</code>, <code className="text-[var(--color-text-secondary)]">CLAUDE.md</code>, <code className="text-[var(--color-text-secondary)]">*.instructions.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/instructions/</code>, or <code className="text-[var(--color-text-secondary)]">*.md</code> files under <code className="text-[var(--color-text-secondary)]">.claude/rules/</code>.
</EmptyState>
) : (
<div className="space-y-3">
@@ -359,6 +366,8 @@ function InstructionsContent({
function InstructionCard({ instruction }: { instruction: ProjectInstructionFile }) {
const [expanded, setExpanded] = useState(false);
const isLong = instruction.content.length > 300;
const isAncestor = instruction.sourcePath.startsWith('..');
const displayName = instruction.name ?? (isAncestor ? formatSettingsSourcePath(instruction.sourcePath) : instruction.sourcePath);
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)]">
@@ -368,13 +377,35 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
type="button"
>
<FileCode2 className="size-4 shrink-0 text-[var(--color-text-accent)]" />
<span className="flex-1 text-[13px] font-medium text-[var(--color-text-primary)]">{instruction.sourcePath}</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]" title={instruction.sourcePath}>{displayName}</span>
<InstructionModeBadge mode={instruction.applicationMode} />
{isAncestor && (
<span className="shrink-0 rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
{instruction.applyTo && (
<span
className="truncate rounded bg-[var(--color-surface-3)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
title={`Applies to files matching: ${instruction.applyTo}`}
>
{instruction.applyTo}
</span>
)}
</div>
<ChevronDown
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)] px-5 py-4">
{instruction.description && (
<p className="mb-2 text-[11px] italic text-[var(--color-text-muted)]">{instruction.description}</p>
)}
{instruction.name && (
<p className="mb-2 text-[11px] text-[var(--color-text-muted)]">{instruction.sourcePath}</p>
)}
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{instruction.content}
</pre>
@@ -382,6 +413,9 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
)}
{!expanded && (
<div className="px-5 pb-3">
{instruction.description && (
<p className="mb-1 text-[11px] italic text-[var(--color-text-muted)]">{instruction.description}</p>
)}
<p className={`text-[11px] leading-relaxed text-[var(--color-text-muted)] ${isLong ? 'line-clamp-2' : ''}`}>
{instruction.content}
</p>
@@ -391,6 +425,38 @@ function InstructionCard({ instruction }: { instruction: ProjectInstructionFile
);
}
function InstructionModeBadge({ mode }: { mode: ProjectInstructionApplicationMode }) {
switch (mode) {
case 'always':
return (
<span className="shrink-0 rounded-full bg-[var(--color-status-success)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-success)]">
always
</span>
);
case 'file':
return (
<span className="shrink-0 rounded-full bg-[var(--color-text-accent)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-accent)]">
file
</span>
);
case 'task':
return (
<span className="shrink-0 rounded-full bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
task
</span>
);
case 'manual':
return (
<span
className="shrink-0 rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]"
title="Discovered but not auto-applied to sessions"
>
manual
</span>
);
}
}
/* ── Custom Agents ────────────────────────────────────────── */
function AgentsContent({
@@ -407,7 +473,7 @@ function AgentsContent({
return (
<div>
<SectionHeader
description="Custom agent profiles discovered from .github/agents/*.agent.md. Enable or disable individual agents."
description="Custom agent profiles discovered from .github/agents/**/*.agent.md. Enable or disable individual agents."
title="Custom Agents"
>
<RescanButton onClick={onRescan} />
@@ -415,7 +481,7 @@ function AgentsContent({
{agents.length === 0 ? (
<EmptyState>
No custom agents found. Add <code className="text-[var(--color-text-secondary)]">.agent.md</code> files to <code className="text-[var(--color-text-secondary)]">.github/agents/</code> in your project.
No custom agents found. Add <code className="text-[var(--color-text-secondary)]">.agent.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/agents/</code> in your project.
</EmptyState>
) : (
<>
@@ -496,7 +562,7 @@ function PromptsContent({
return (
<div>
<SectionHeader
description="Reusable prompt templates discovered from .github/prompts/*.prompt.md. Use them from the Prompts pill in the chat input."
description="Reusable prompt templates discovered from .github/prompts/**/*.prompt.md. Use them from the Prompts pill in the chat input."
title="Prompt Files"
>
<RescanButton onClick={onRescan} />
@@ -504,7 +570,7 @@ function PromptsContent({
{promptFiles.length === 0 ? (
<EmptyState>
No prompt files found. Add <code className="text-[var(--color-text-secondary)]">.prompt.md</code> files to <code className="text-[var(--color-text-secondary)]">.github/prompts/</code> in your project.
No prompt files found. Add <code className="text-[var(--color-text-secondary)]">.prompt.md</code> files under <code className="text-[var(--color-text-secondary)]">.github/prompts/</code> in your project.
</EmptyState>
) : (
<div className="space-y-2">
@@ -518,6 +584,8 @@ function PromptsContent({
}
function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
const isAncestor = prompt.sourcePath.startsWith('..');
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-glass)] px-5 py-4">
<div className="flex items-start gap-3">
@@ -525,15 +593,47 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{prompt.name}</span>
{prompt.model && (
<span className="rounded-full bg-[var(--color-accent-purple)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</span>
)}
{prompt.agent && (
<span className="rounded-full bg-[var(--color-accent-sky)]/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{prompt.agent}
</span>
)}
{prompt.variables.length > 0 && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{prompt.variables.length} variable{prompt.variables.length === 1 ? '' : 's'}
</span>
)}
{isAncestor && (
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
parent repo
</span>
)}
</div>
{prompt.description && (
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-muted)]">{prompt.description}</p>
)}
{prompt.argumentHint && (
<p className="mt-1 text-[11px] italic text-[var(--color-text-muted)]">
Hint: {prompt.argumentHint}
</p>
)}
{prompt.tools && prompt.tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{prompt.tools.map((tool) => (
<span
key={tool}
className="rounded-md bg-[var(--color-status-warning)]/10 px-2 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]"
>
{tool}
</span>
))}
</div>
)}
{prompt.variables.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{prompt.variables.map((v) => (
@@ -547,7 +647,9 @@ function PromptCard({ prompt }: { prompt: ProjectPromptFile }) {
))}
</div>
)}
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">{prompt.sourcePath}</p>
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]" title={prompt.sourcePath}>
{isAncestor ? formatSettingsSourcePath(prompt.sourcePath) : prompt.sourcePath}
</p>
</div>
</div>
</div>
-364
View File
@@ -1,364 +0,0 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
AlertTriangle,
ArrowRight,
Bot,
Brain,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleDot,
MessageSquare,
Play,
Wrench,
XCircle,
} from 'lucide-react';
import {
collapseTimelineEvents,
formatEventLabel,
formatRunDuration,
formatRunStatusLabel,
formatRunTimestamp,
isTerminalEvent,
truncateContent,
type CollapsedTimelineEvent,
} from '@renderer/lib/runTimelineFormatting';
import type { OrchestrationMode } from '@shared/domain/pattern';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
/* ── Mode accent colours (shared with ActivityPanel) ───────── */
const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: string }> = {
single: { dot: 'bg-[#245CF9]', ring: 'ring-[#245CF9]/30', text: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', ring: 'ring-[var(--color-status-warning)]/30', text: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', ring: 'ring-[var(--color-status-success)]/30', text: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', ring: 'ring-[var(--color-accent-sky)]/30', text: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', ring: 'ring-[var(--color-accent-purple)]/30', text: 'text-[var(--color-accent-purple)]' },
magentic: { dot: 'bg-[var(--color-text-muted)]', ring: 'ring-[var(--color-text-muted)]/30', text: 'text-[var(--color-text-muted)]' },
};
/* ── Status badges ─────────────────────────────────────────── */
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
running: { icon: <CircleDot className="size-3" />, className: 'text-[var(--color-status-info)]' },
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-[var(--color-status-success)]' },
cancelled: { icon: <XCircle className="size-3" />, className: 'text-[var(--color-text-muted)]' },
error: { icon: <XCircle className="size-3" />, className: 'text-[var(--color-status-error)]' },
};
/* ── Event node icon ───────────────────────────────────────── */
function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
const isRunning = status === 'running';
const base = 'size-2.5';
// Running events use white icons to contrast with the brand-gradient circle
if (isRunning) {
const pulse = 'animate-pulse';
switch (kind) {
case 'thinking':
return <Brain className={`${base} ${pulse} text-white`} />;
case 'approval':
return <AlertTriangle className={`${base} ${pulse} text-white`} />;
case 'message':
return <MessageSquare className={`${base} ${pulse} text-white`} />;
default:
return <Play className={`${base} text-white`} />;
}
}
switch (kind) {
case 'run-started':
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
case 'thinking':
return <Brain className={`${base} text-[var(--color-text-muted)]`} />;
case 'handoff':
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
case 'tool-call':
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
case 'approval':
return <AlertTriangle className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
case 'message':
return <MessageSquare className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
case 'run-cancelled':
return <XCircle className={`${base} text-[var(--color-text-muted)]`} />;
case 'run-failed':
return <AlertTriangle className={`${base} text-[var(--color-status-error)]`} />;
}
}
/* ── Single timeline event row ─────────────────────────────── */
function TimelineEventRow({
event,
isLast,
onJumpToMessage,
}: {
event: RunTimelineEventRecord;
isLast: boolean;
onJumpToMessage?: (messageId: string) => void;
}) {
const label = formatEventLabel(event);
const timestamp = formatRunTimestamp(event.updatedAt ?? event.occurredAt);
const preview = event.kind === 'message' ? truncateContent(event.content) : undefined;
const isClickable = !!onJumpToMessage && !!event.messageId;
const terminal = isTerminalEvent(event.kind);
return (
<div className="relative">
{/* Vertical connector line */}
{!isLast && (
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
)}
<button
className={`group flex w-full gap-2.5 text-left transition-all duration-200 ${terminal ? 'py-1' : 'py-1.5'} ${isClickable ? 'cursor-pointer' : 'cursor-default'}`}
disabled={!isClickable}
onClick={isClickable ? () => onJumpToMessage(event.messageId!) : undefined}
type="button"
>
{/* Node */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className={`flex size-[18px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
<EventIcon kind={event.kind} status={event.status} />
</div>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-medium ${terminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'} ${isClickable ? 'group-hover:text-[var(--color-text-accent)]' : ''}`}>
{label}
</span>
{/* Approval kind badge */}
{event.kind === 'approval' && event.approvalKind && (
<span className={`rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
event.status === 'running'
? 'bg-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
: event.status === 'completed'
? 'bg-[var(--color-status-success)]/15 text-[var(--color-status-success)]'
: 'bg-[var(--color-status-error)]/15 text-[var(--color-status-error)]'
}`}>
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
</span>
)}
<span className="font-mono ml-auto shrink-0 text-[9px] tabular-nums text-[var(--color-text-muted)]">{timestamp}</span>
</div>
{/* Content preview for message events */}
{preview && (
<p className={`mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)] ${isClickable ? 'group-hover:text-[var(--color-text-secondary)]' : ''}`}>
{preview}
</p>
)}
{/* Approval detail */}
{event.kind === 'approval' && event.approvalDetail && (
<p className="mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)]">
{truncateContent(event.approvalDetail, 120)}
</p>
)}
{/* Error detail */}
{event.error && (
<p className="mt-0.5 text-[10px] leading-snug text-[var(--color-status-error)]/80">
{truncateContent(event.error, 120)}
</p>
)}
</div>
</button>
{/* File change preview for tool-call events */}
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
<div className="relative z-10 ml-[25px] pb-1">
<FileChangePreview fileChanges={event.fileChanges} />
</div>
)}
</div>
);
}
/* ── Collapsed thinking group ──────────────────────────────── */
function ThinkingGroupRow({
events,
agentName,
isLast,
}: {
events: RunTimelineEventRecord[];
agentName: string;
isLast: boolean;
}) {
return (
<div className="group relative flex w-full gap-2.5 py-1">
{!isLast && (
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
)}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className="flex size-[18px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
<Brain className="size-2.5 text-[var(--color-text-muted)]" />
</div>
</div>
<div className="min-w-0 flex-1">
<span className="text-[11px] text-[var(--color-text-muted)]">
{agentName ? `${agentName} thinking` : 'Thinking'} ×{events.length}
</span>
</div>
</div>
);
}
/* ── Collapsed event dispatcher ────────────────────────────── */
function CollapsedEventRow({
item,
isLast,
onJumpToMessage,
}: {
item: CollapsedTimelineEvent;
isLast: boolean;
onJumpToMessage?: (messageId: string) => void;
}) {
if (item.type === 'thinking-group') {
return <ThinkingGroupRow agentName={item.agentName} events={item.events} isLast={isLast} />;
}
return <TimelineEventRow event={item.event} isLast={isLast} onJumpToMessage={onJumpToMessage} />;
}
/* ── Run card ──────────────────────────────────────────────── */
function RunCard({
run,
expanded,
onToggle,
onJumpToMessage,
}: {
run: SessionRunRecord;
expanded: boolean;
onToggle: () => void;
onJumpToMessage?: (messageId: string) => void;
}) {
const accent = modeAccent[run.patternMode] ?? modeAccent.single;
const statusStyle = runStatusStyles[run.status];
const duration = formatRunDuration(run.startedAt, run.completedAt);
const collapsedEvents = useMemo(
() => collapseTimelineEvents(run.events),
[run.events],
);
return (
<div className="glass-surface rounded-lg">
{/* Run header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={onToggle}
type="button"
>
{expanded
? <ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />}
<Bot className={`size-3 shrink-0 ${accent.text}`} />
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-[var(--color-text-secondary)]">
{run.patternName}
</span>
{/* Status */}
<span className={`flex items-center gap-1 shrink-0 ${statusStyle.className}`}>
{run.status === 'running' && <span className="size-1.5 animate-pulse rounded-full bg-[var(--color-status-info)]" />}
{run.status !== 'running' && statusStyle.icon}
<span className="text-[9px] font-medium">{formatRunStatusLabel(run.status)}</span>
</span>
</button>
{/* Expanded timeline */}
{expanded && (
<div className="border-t border-[var(--color-border-subtle)] px-3 pb-2 pt-1.5">
{/* Agent badges */}
{run.agents.length > 1 && (
<div className="mb-2 flex flex-wrap gap-1">
{run.agents.map((agent) => (
<span
className="rounded-full bg-[var(--color-surface-2)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]"
key={agent.agentId}
>
{agent.agentName}
</span>
))}
</div>
)}
{/* Timeline events */}
<div>
{collapsedEvents.map((item, index) => (
<CollapsedEventRow
isLast={index === collapsedEvents.length - 1}
item={item}
key={item.type === 'single' ? item.event.id : `thinking-${item.events[0].id}`}
onJumpToMessage={onJumpToMessage}
/>
))}
</div>
{/* Duration footer */}
{duration && (
<div className="font-mono mt-1 border-t border-[var(--color-border-subtle)] pt-1.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
Duration: {duration}
</div>
)}
</div>
)}
</div>
);
}
/* ── Empty state ───────────────────────────────────────────── */
function EmptyTimeline() {
return (
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">
Send a message to see the run timeline
</p>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface RunTimelineProps {
runs: readonly SessionRunRecord[];
onJumpToMessage?: (messageId: string) => void;
}
export function RunTimeline({ runs, onJumpToMessage }: RunTimelineProps) {
const latestRunId = runs.length > 0 ? runs[0].id : undefined;
const [expandedRunId, setExpandedRunId] = useState<string | undefined>(latestRunId);
// Auto-expand the latest run when it changes
useEffect(() => {
setExpandedRunId(latestRunId);
}, [latestRunId]);
if (runs.length === 0) {
return <EmptyTimeline />;
}
return (
<div className="space-y-2">
{runs.map((run) => (
<RunCard
expanded={expandedRunId === run.id}
key={run.id}
onJumpToMessage={onJumpToMessage}
onToggle={() => setExpandedRunId(expandedRunId === run.id ? undefined : run.id)}
run={run}
/>
))}
</div>
);
}
+350 -79
View File
@@ -1,17 +1,20 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
import { getElectronApi } from '@renderer/lib/electronApi';
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shared/domain/workflowTemplate';
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
@@ -20,10 +23,11 @@ import {
type McpServerDefinition,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
sidecarCapabilities?: SidecarCapabilities;
theme: AppearanceTheme;
toolingSettings: WorkspaceToolingSettings;
@@ -32,27 +36,35 @@ interface SettingsPanelProps {
initialSection?: SettingsSection;
onRefreshCapabilities: () => void;
onClose: () => void;
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
onDeletePattern: (patternId: string) => Promise<void>;
onNewPattern: () => PatternDefinition;
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
onDeleteWorkflow: (workflowId: string) => Promise<void>;
onNewWorkflow: () => WorkflowDefinition;
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
onDeleteMcpServer: (serverId: string) => Promise<void>;
onNewMcpServer: () => McpServerDefinition;
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
onDeleteLspProfile: (profileId: string) => Promise<void>;
onNewLspProfile: () => LspProfileDefinition;
onSaveWorkspaceAgent: (agent: WorkspaceAgentDefinition) => Promise<void>;
onDeleteWorkspaceAgent: (agentId: string) => Promise<void>;
onNewWorkspaceAgent: () => WorkspaceAgentDefinition;
workspaceAgents: WorkspaceAgentDefinition[];
onSetTheme: (theme: AppearanceTheme) => void;
notificationsEnabled: boolean;
onSetNotificationsEnabled: (enabled: boolean) => void;
minimizeToTray: boolean;
onSetMinimizeToTray: (enabled: boolean) => void;
gitAutoRefreshEnabled: boolean;
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
onOpenAppDataFolder: () => void;
onResetLocalWorkspace: () => Promise<void>;
onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void;
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
workflowTemplates?: WorkflowTemplateDefinition[];
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
}
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -79,9 +91,10 @@ const navGroups: NavGroup[] = [
],
},
{
label: 'Orchestration',
label: 'Workflows',
items: [
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
],
},
{
@@ -99,14 +112,9 @@ const navGroups: NavGroup[] = [
},
];
function modeBadgeClasses(pattern: PatternDefinition) {
if (pattern.availability === 'unavailable') return 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]';
return 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]';
}
export function SettingsPanel({
availableModels,
patterns,
workflows,
sidecarCapabilities,
theme,
toolingSettings,
@@ -115,54 +123,75 @@ export function SettingsPanel({
initialSection,
onRefreshCapabilities,
onClose,
onSavePattern,
onDeletePattern,
onNewPattern,
onSaveWorkflow,
onDeleteWorkflow,
onNewWorkflow,
onSaveMcpServer,
onDeleteMcpServer,
onNewMcpServer,
onSaveLspProfile,
onDeleteLspProfile,
onNewLspProfile,
onSaveWorkspaceAgent,
onDeleteWorkspaceAgent,
onNewWorkspaceAgent,
workspaceAgents,
onSetTheme,
notificationsEnabled,
onSetNotificationsEnabled,
minimizeToTray,
onSetMinimizeToTray,
gitAutoRefreshEnabled,
onSetGitAutoRefreshEnabled,
onOpenAppDataFolder,
onResetLocalWorkspace,
onResolveUserDiscoveredTooling,
onGetQuota,
workflowTemplates,
onCreateWorkflowFromTemplate,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
if (editingPattern) {
const isBuiltin = editingPattern.id.startsWith('pattern-');
if (editingWorkflow) {
const api = getElectronApi();
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<PatternEditor
<WorkflowEditor
availableModels={availableModels}
isBuiltin={isBuiltin}
onBack={() => setEditingPattern(null)}
onChange={setEditingPattern}
onBack={() => setEditingWorkflow(null)}
onChange={setEditingWorkflow}
onDelete={
isBuiltin
? undefined
: async () => {
await onDeletePattern(editingPattern.id);
setEditingPattern(null);
}
async () => {
try {
await onDeleteWorkflow(editingWorkflow.id);
setEditingWorkflow(null);
} catch (err) {
window.alert(
err instanceof Error
? err.message
: 'Cannot delete this workflow. It may be referenced by other workflows.',
);
}
}
}
onSave={async () => {
await onSavePattern(editingPattern);
setEditingPattern(null);
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
setEditingWorkflow(null);
}}
pattern={editingPattern}
runtimeTools={sidecarCapabilities?.runtimeTools}
toolingSettings={toolingSettings}
onExportWorkflow={async (format) => {
const result = await api.exportWorkflow({ workflowId: editingWorkflow.id, format });
return result;
}}
onImportWorkflow={async (content, format) => {
const result = await api.importWorkflow({ content, format, options: { save: false } });
return result.workflow;
}}
workflow={editingWorkflow}
workflows={workflows}
/>
</div>
);
@@ -218,6 +247,33 @@ export function SettingsPanel({
);
}
if (editingWorkspaceAgent) {
const exists = workspaceAgents.some((a) => a.id === editingWorkspaceAgent.id);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<WorkspaceAgentEditor
agent={editingWorkspaceAgent}
availableModels={availableModels}
onBack={() => setEditingWorkspaceAgent(null)}
onChange={setEditingWorkspaceAgent}
onDelete={
exists
? async () => {
await onDeleteWorkspaceAgent(editingWorkspaceAgent.id);
setEditingWorkspaceAgent(null);
}
: undefined
}
onSave={async () => {
await onSaveWorkspaceAgent(normalizeWorkspaceAgentDefinition(editingWorkspaceAgent));
setEditingWorkspaceAgent(null);
}}
workflows={workflows}
/>
</div>
);
}
return (
<div className="overlay-slide-enter fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<div className="drag-region flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-3">
@@ -274,6 +330,8 @@ export function SettingsPanel({
onSetNotificationsEnabled={onSetNotificationsEnabled}
minimizeToTray={minimizeToTray}
onSetMinimizeToTray={onSetMinimizeToTray}
gitAutoRefreshEnabled={gitAutoRefreshEnabled}
onSetGitAutoRefreshEnabled={onSetGitAutoRefreshEnabled}
/>
)}
{activeSection === 'connection' && (
@@ -285,11 +343,21 @@ export function SettingsPanel({
onGetQuota={onGetQuota}
/>
)}
{activeSection === 'patterns' && (
<PatternsSection
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
onNewPattern={() => setEditingPattern(onNewPattern())}
patterns={patterns}
{activeSection === 'workflows' && (
<WorkflowsSection
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
workflows={workflows}
workflowTemplates={workflowTemplates}
onCreateWorkflowFromTemplate={onCreateWorkflowFromTemplate}
/>
)}
{activeSection === 'agents' && (
<WorkspaceAgentsSection
agents={workspaceAgents}
workflows={workflows}
onEditAgent={(agent) => setEditingWorkspaceAgent(structuredClone(agent))}
onNewAgent={() => setEditingWorkspaceAgent(onNewWorkspaceAgent())}
/>
)}
{activeSection === 'mcp-servers' && (
@@ -338,6 +406,8 @@ function AppearanceSection({
onSetNotificationsEnabled,
minimizeToTray,
onSetMinimizeToTray,
gitAutoRefreshEnabled,
onSetGitAutoRefreshEnabled,
}: {
theme: AppearanceTheme;
onSetTheme: (theme: AppearanceTheme) => void;
@@ -345,6 +415,8 @@ function AppearanceSection({
onSetNotificationsEnabled: (enabled: boolean) => void;
minimizeToTray: boolean;
onSetMinimizeToTray: (enabled: boolean) => void;
gitAutoRefreshEnabled: boolean;
onSetGitAutoRefreshEnabled: (enabled: boolean) => void;
}){
return (
<div>
@@ -434,6 +506,30 @@ function AppearanceSection({
</div>
<ToggleSwitch enabled={minimizeToTray} />
</button>
{/* Git */}
<div className="mt-8 mb-1">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Git</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Control how Aryx monitors your repositories
</p>
</div>
<button
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
onClick={() => onSetGitAutoRefreshEnabled(!gitAutoRefreshEnabled)}
type="button"
>
<div>
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Auto-refresh git status
</span>
<p className="text-[12px] text-[var(--color-text-muted)]">
Periodically poll repository status in the background and refresh on window focus
</p>
</div>
<ToggleSwitch enabled={gitAutoRefreshEnabled} />
</button>
</div>
);
}
@@ -472,50 +568,225 @@ function ConnectionSection({
);
}
function PatternsSection({
patterns,
onEditPattern,
onNewPattern,
const categoryLabels: Record<WorkflowTemplateCategory, string> = {
'orchestration': 'Orchestration',
'data-pipeline': 'Data Pipeline',
'human-in-loop': 'Human-in-Loop',
};
function WorkflowsSection({
workflows,
onEditWorkflow,
onNewWorkflow,
workflowTemplates,
onCreateWorkflowFromTemplate,
}: {
patterns: PatternDefinition[];
onEditPattern: (pattern: PatternDefinition) => void;
onNewPattern: () => void;
workflows: WorkflowDefinition[];
onEditWorkflow: (workflow: WorkflowDefinition) => void;
onNewWorkflow: () => void;
workflowTemplates?: WorkflowTemplateDefinition[];
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
}) {
const [categoryFilter, setCategoryFilter] = useState<WorkflowTemplateCategory | 'all'>('all');
const filteredTemplates = (workflowTemplates ?? []).filter(
(t) => categoryFilter === 'all' || t.category === categoryFilter,
);
return (
<div>
<SectionHeader
description="Design multi-step agent workflows with visual graphs"
title="Workflows"
>
<SectionAction label="New Workflow" onClick={onNewWorkflow} />
</SectionHeader>
<div className="space-y-1">
{workflows.map((wf) => {
const agentCount = wf.graph.nodes.filter((n) => n.kind === 'agent').length;
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
key={wf.id}
onClick={() => onEditWorkflow(wf)}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{wf.name}</span>
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
{wf.settings.executionMode}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{wf.description}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] text-[var(--color-text-muted)]">
{agentCount} agent{agentCount === 1 ? '' : 's'}
</span>
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
</div>
</button>
);
})}
{workflows.length === 0 && (
<p className="px-4 py-6 text-center text-[12px] text-[var(--color-text-muted)]">
No workflows yet. Create one to get started.
</p>
)}
</div>
{/* Template Gallery */}
{workflowTemplates && workflowTemplates.length > 0 && (
<div className="mt-8">
<div className="mb-3">
<h4 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Templates</h4>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Start from a pre-built workflow template
</p>
</div>
<div className="mb-4 flex gap-1.5">
{(['all', 'orchestration', 'data-pipeline', 'human-in-loop'] as const).map((cat) => (
<button
key={cat}
className={`rounded-lg px-2.5 py-1 text-[11px] font-medium transition-all duration-200 ${
categoryFilter === cat
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => setCategoryFilter(cat)}
type="button"
>
{cat === 'all' ? 'All' : categoryLabels[cat]}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
{filteredTemplates.map((template) => {
const nodeCount = template.workflow.graph.nodes.length;
const agentCount = template.workflow.graph.nodes.filter((n) => n.kind === 'agent').length;
return (
<div
key={template.id}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)]/50 p-4 transition-all duration-200 hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-1)]"
>
<div className="mb-2 flex items-start justify-between gap-2">
<h5 className="text-[13px] font-medium text-[var(--color-text-primary)]">{template.name}</h5>
<div className="flex shrink-0 gap-1">
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
{categoryLabels[template.category]}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
template.source === 'builtin'
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)]'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)]'
}`}>
{template.source}
</span>
</div>
</div>
<p className="mb-3 line-clamp-2 text-[12px] leading-relaxed text-[var(--color-text-muted)]">
{template.description}
</p>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--color-text-muted)]">
{nodeCount} node{nodeCount === 1 ? '' : 's'} · {agentCount} agent{agentCount === 1 ? '' : 's'}
</span>
{onCreateWorkflowFromTemplate && (
<button
className="rounded-lg bg-[var(--color-accent)] px-3 py-1 text-[11px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
onClick={() => void onCreateWorkflowFromTemplate(template.id)}
type="button"
>
Use Template
</button>
)}
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<p className="py-6 text-center text-[12px] text-[var(--color-text-muted)]">
No templates in this category.
</p>
)}
</div>
)}
</div>
);
}
function WorkspaceAgentsSection({
agents,
workflows,
onEditAgent,
onNewAgent,
}: {
agents: WorkspaceAgentDefinition[];
workflows: WorkflowDefinition[];
onEditAgent: (agent: WorkspaceAgentDefinition) => void;
onNewAgent: () => void;
}) {
return (
<div>
<SectionHeader
description="Define reusable agent configurations for your sessions"
title="Orchestration Patterns"
description="Define reusable agents that can be shared across multiple workflows"
title="Workspace Agents"
>
<SectionAction label="New Pattern" onClick={onNewPattern} />
<SectionAction label="New Agent" onClick={onNewAgent} />
</SectionHeader>
<div className="space-y-1">
{patterns.map((pattern) => (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
key={pattern.id}
onClick={() => onEditPattern(pattern)}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{pattern.name}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${modeBadgeClasses(pattern)}`}>
{pattern.mode}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{pattern.description}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] text-[var(--color-text-muted)]">
{pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'}
</span>
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
</div>
</button>
))}
</div>
{agents.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] px-6 py-10 text-center">
<UserCircle className="mx-auto size-8 text-[var(--color-text-muted)]" />
<p className="mt-3 text-[13px] font-medium text-[var(--color-text-secondary)]">
No workspace agents yet
</p>
<p className="mt-1 text-[12px] text-[var(--color-text-muted)]">
Create agents here and reference them in multiple workflows.
Changes to a workspace agent automatically propagate to all linked workflows.
</p>
</div>
) : (
<div className="space-y-1">
{agents.map((agent) => {
const usageCount = findWorkspaceAgentUsages(agent.id, workflows).length;
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
key={agent.id}
onClick={() => onEditAgent(agent)}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{agent.name}</span>
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{agent.model}
</span>
</div>
{agent.description && (
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{agent.description}</p>
)}
</div>
<div className="flex items-center gap-2">
{usageCount > 0 && (
<span className="text-[12px] text-[var(--color-text-muted)]">
{usageCount} workflow{usageCount === 1 ? '' : 's'}
</span>
)}
<ChevronRight className="size-4 text-[var(--color-text-muted)]" />
</div>
</button>
);
})}
</div>
)}
</div>
);
}
@@ -957,7 +1228,7 @@ function TroubleshootingSection({
<div className="min-w-0 flex-1">
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
Restore Aryx to its initial state. This permanently removes all sessions, custom workflows,
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
is not affected.
</p>
+40 -23
View File
@@ -13,7 +13,6 @@ import {
GitBranch,
GitFork,
ListOrdered,
Lock,
MessageSquare,
MoreHorizontal,
Pencil,
@@ -28,7 +27,7 @@ import {
type LucideIcon,
} from 'lucide-react';
import type { OrchestrationMode, PatternDefinition } from '@shared/domain/pattern';
import { resolveWorkflowAgentNodes, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import { isScratchpadProject, type ProjectRecord, type ProjectGitContext } from '@shared/domain/project';
import { listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
import type { SessionRecord } from '@shared/domain/session';
@@ -59,13 +58,12 @@ interface SidebarProps {
/* ── Mode icon + accent colour mapping ─────────────────────── */
const modeVisuals: Record<OrchestrationMode, { icon: LucideIcon; color: string }> = {
const modeVisuals: Record<WorkflowOrchestrationMode, { icon: LucideIcon; color: string }> = {
single: { icon: MessageSquare, color: 'text-[#245CF9]' },
sequential: { icon: ListOrdered, color: 'text-[var(--color-status-warning)]' },
concurrent: { icon: GitFork, color: 'text-[var(--color-status-success)]' },
handoff: { icon: ArrowLeftRight, color: 'text-[var(--color-accent-sky)]' },
'group-chat': { icon: Users, color: 'text-[var(--color-accent-purple)]' },
magentic: { icon: Lock, color: 'text-[var(--color-text-muted)]' },
};
/* ── Relative time helper ──────────────────────────────────── */
@@ -122,8 +120,25 @@ function GitContextBadge({ git }: { git: ProjectGitContext }) {
if (git.ahead) parts.push(`${git.ahead}`);
if (git.behind) parts.push(`${git.behind}`);
const tooltipLines: string[] = [branchLabel];
if (git.changes) {
const breakdown: string[] = [];
if (git.changes.staged > 0) breakdown.push(`${git.changes.staged} staged`);
if (git.changes.unstaged > 0) breakdown.push(`${git.changes.unstaged} modified`);
if (git.changes.untracked > 0) breakdown.push(`${git.changes.untracked} untracked`);
if (git.changes.conflicted > 0) breakdown.push(`${git.changes.conflicted} conflicted`);
if (breakdown.length > 0) tooltipLines.push(breakdown.join(', '));
}
if (git.ahead || git.behind) {
const sync: string[] = [];
if (git.ahead) sync.push(`${git.ahead} ahead`);
if (git.behind) sync.push(`${git.behind} behind`);
tooltipLines.push(sync.join(', '));
}
if (git.upstream) tooltipLines.push(`${git.upstream}`);
return (
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" title={parts.join(' · ') || branchLabel}>
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" title={tooltipLines.join('\n')}>
<GitBranch className="size-2.5 shrink-0" />
<span className="max-w-[140px] truncate font-mono">{branchLabel}</span>
{git.isDirty && <Circle className="size-1.5 shrink-0 fill-amber-500 text-amber-500" />}
@@ -161,7 +176,7 @@ function ActionMenuItem({
function SessionItem({
session,
pattern,
workflow,
isActive,
isRenaming,
onSelect,
@@ -170,7 +185,7 @@ function SessionItem({
onRenameCancel,
}: {
session: SessionRecord;
pattern?: PatternDefinition;
workflow?: WorkflowDefinition;
isActive: boolean;
isRenaming: boolean;
onSelect: () => void;
@@ -182,10 +197,10 @@ function SessionItem({
const isError = session.status === 'error';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const mode = pattern?.mode ?? 'single';
const mode = workflow?.settings.orchestrationMode ?? 'single';
const visual = modeVisuals[mode];
const ModeIcon = visual.icon;
const agentCount = pattern?.agents.length ?? 1;
const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1;
const [renameText, setRenameText] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -346,7 +361,7 @@ function SessionItem({
function ProjectGroup({
project,
sessions,
patterns,
workflows,
selectedSessionId,
renamingSessionId,
onSessionSelect,
@@ -360,7 +375,7 @@ function ProjectGroup({
}: {
project: ProjectRecord;
sessions: SessionRecord[];
patterns: PatternDefinition[];
workflows: WorkflowDefinition[];
selectedSessionId?: string;
renamingSessionId?: string;
onSessionSelect: (sessionId: string) => void;
@@ -375,11 +390,11 @@ function ProjectGroup({
const [expanded, setExpanded] = useState(true);
const isScratchpad = isScratchpadProject(project);
const patternMap = useMemo(() => {
const map = new Map<string, PatternDefinition>();
for (const p of patterns) map.set(p.id, p);
const workflowMap = useMemo(() => {
const map = new Map<string, WorkflowDefinition>();
for (const w of workflows) map.set(w.id, w);
return map;
}, [patterns]);
}, [workflows]);
const visibleSessions = useMemo(() =>
sessions
@@ -502,7 +517,7 @@ function ProjectGroup({
onOpenMenu={(e) => onOpenMenu(session.id, e)}
onRenameSubmit={(title) => onRenameSubmit(session.id, title)}
onRenameCancel={onRenameCancel}
pattern={patternMap.get(session.patternId)}
workflow={workflowMap.get(session.workflowId)}
session={session}
/>
))}
@@ -558,11 +573,13 @@ export function Sidebar({
const isQueryActive = searchText.trim().length > 0;
const patternMap = useMemo(() => {
const map = new Map<string, PatternDefinition>();
for (const p of workspace.patterns) map.set(p.id, p);
const workflowMap = useMemo(() => {
const map = new Map<string, WorkflowDefinition>();
for (const w of workspace.workflows) {
map.set(w.id, w);
}
return map;
}, [workspace.patterns]);
}, [workspace.workflows]);
const queryResults = useMemo(() => {
if (!isQueryActive) return [];
@@ -677,7 +694,7 @@ export function Sidebar({
onOpenMenu={(e) => handleOpenMenu(session.id, e)}
onRenameSubmit={(title) => handleRenameSubmit(session.id, title)}
onRenameCancel={() => setRenamingSessionId(undefined)}
pattern={patternMap.get(session.patternId)}
workflow={workflowMap.get(session.workflowId)}
session={session}
/>
))
@@ -698,7 +715,7 @@ export function Sidebar({
onRenameSubmit={handleRenameSubmit}
onRenameCancel={() => setRenamingSessionId(undefined)}
renamingSessionId={renamingSessionId}
patterns={workspace.patterns}
workflows={[...workflowMap.values()]}
project={scratchpadProject}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
@@ -747,7 +764,7 @@ export function Sidebar({
onRefreshGitContext={onRefreshGitContext}
onOpenProjectSettings={onOpenProjectSettings}
renamingSessionId={renamingSessionId}
patterns={workspace.patterns}
workflows={[...workflowMap.values()]}
project={project}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
+17 -103
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RotateCcw, Minus, X } from 'lucide-react';
import { RotateCcw } from 'lucide-react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
@@ -44,17 +44,11 @@ const DEFAULT_HEIGHT = 280;
/* ── TerminalPanel ────────────────────────────────────────── */
interface TerminalPanelProps {
height: number;
onHeightChange: (height: number) => void;
onClose: () => void;
onMinimize: () => void;
onRunningChange?: (running: boolean) => void;
}
export function TerminalPanel({
height,
onHeightChange,
onClose,
onMinimize,
onRunningChange,
}: TerminalPanelProps) {
const api = getElectronApi();
const containerRef = useRef<HTMLDivElement>(null);
@@ -62,8 +56,6 @@ export function TerminalPanel({
const fitAddonRef = useRef<FitAddon | null>(null);
const [snapshot, setSnapshot] = useState<TerminalSnapshot>();
const [isRunning, setIsRunning] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ y: number; height: number } | null>(null);
// Create or recover terminal on mount
useEffect(() => {
@@ -74,11 +66,13 @@ export function TerminalPanel({
if (existing) {
setSnapshot(existing);
setIsRunning(true);
onRunningChange?.(true);
} else {
void api.createTerminal().then((created) => {
if (disposed) return;
setSnapshot(created);
setIsRunning(true);
onRunningChange?.(true);
});
}
});
@@ -135,6 +129,7 @@ export function TerminalPanel({
});
const offExit = api.onTerminalExit((_info: TerminalExitInfo) => {
setIsRunning(false);
onRunningChange?.(false);
terminalRef.current?.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
});
@@ -144,19 +139,7 @@ export function TerminalPanel({
};
}, [api]);
// Refit on height changes
useEffect(() => {
if (!fitAddonRef.current || !terminalRef.current) return;
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
const terminal = terminalRef.current;
if (terminal) {
api.resizeTerminal({ cols: terminal.cols, rows: terminal.rows });
}
});
}, [height, api]);
// ResizeObserver for container width changes
// ResizeObserver for container size changes (width or height from parent)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -174,100 +157,31 @@ export function TerminalPanel({
return () => observer.disconnect();
}, [api]);
// Drag-to-resize
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragStartRef.current = { y: e.clientY, height };
setIsDragging(true);
const handleDragMove = (moveEvent: MouseEvent) => {
if (!dragStartRef.current) return;
const maxHeight = window.innerHeight * MAX_HEIGHT_FRACTION;
const delta = dragStartRef.current.y - moveEvent.clientY;
const nextHeight = Math.max(MIN_HEIGHT, Math.min(maxHeight, dragStartRef.current.height + delta));
onHeightChange(nextHeight);
};
const handleDragEnd = () => {
setIsDragging(false);
dragStartRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
};
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
}, [height, onHeightChange]);
const handleRestart = useCallback(() => {
void api.restartTerminal().then((restarted) => {
setSnapshot(restarted);
setIsRunning(true);
onRunningChange?.(true);
terminalRef.current?.clear();
});
}, [api]);
const handleClose = useCallback(() => {
void api.killTerminal();
onClose();
}, [api, onClose]);
return (
<div
className="flex flex-col border-t border-[var(--color-border)] bg-[var(--color-surface-0)]"
style={{ height, minHeight: MIN_HEIGHT }}
>
{/* Resize handle */}
<div
className={`h-1 shrink-0 cursor-row-resize transition-colors ${isDragging ? 'bg-[var(--color-accent)]/40' : 'hover:bg-[var(--color-surface-3)]/60'}`}
onMouseDown={handleDragStart}
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
onHeightChange(Math.min(window.innerHeight * MAX_HEIGHT_FRACTION, height + 20));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onHeightChange(Math.max(MIN_HEIGHT, height - 20));
}
}}
/>
<div className="flex min-h-0 flex-1 flex-col">
{/* Header bar */}
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
<span className={`size-1.5 shrink-0 rounded-full ${isRunning ? 'bg-emerald-400' : 'bg-[var(--color-text-muted)]'}`} />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-[var(--color-text-muted)]">
{snapshot ? `${snapshot.shell}${snapshot.cwd}` : 'Terminal'}
</span>
<div className="flex items-center gap-0.5">
<button
aria-label="Restart terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={handleRestart}
type="button"
>
<RotateCcw className="size-3" />
</button>
<button
aria-label="Minimize terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onMinimize}
type="button"
>
<Minus className="size-3" />
</button>
<button
aria-label="Close terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-red-400"
onClick={handleClose}
type="button"
>
<X className="size-3" />
</button>
</div>
<button
aria-label="Restart terminal"
className="rounded p-0.5 text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={handleRestart}
type="button"
>
<RotateCcw className="size-3" />
</button>
</div>
{/* Terminal body */}
+35 -2
View File
@@ -1,7 +1,9 @@
import { CheckCircle2, Circle, FolderPlus, MessageSquarePlus, Settings, Zap } from 'lucide-react';
import { CheckCircle2, Circle, Download, FolderPlus, MessageSquarePlus, Settings, Zap } from 'lucide-react';
import { motion } from 'motion/react';
import type { SidecarConnectionStatus } from '@shared/contracts/sidecar';
import { detectedPlatform } from '@renderer/lib/platform';
import { getInstallInfoForPlatform } from '@renderer/lib/cliInstallInstructions';
import appIconUrl from '../../../assets/icons/icon.png';
interface WelcomePaneProps {
@@ -71,6 +73,34 @@ function SetupStep({ label, done, active }: SetupStepProps) {
);
}
function CliMissingCard({ onOpenSettings }: { onOpenSettings: () => void }) {
const info = getInstallInfoForPlatform(detectedPlatform);
const recommended = info.methods.find((m) => m.recommended) ?? info.methods[0];
return (
<button
type="button"
onClick={onOpenSettings}
className="group flex w-full cursor-pointer items-start gap-4 rounded-xl border border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] px-5 py-4 text-left shadow-[0_0_24px_rgba(36,92,249,0.1)] backdrop-blur-sm transition-all duration-200 hover:shadow-[0_0_32px_rgba(36,92,249,0.15)]"
>
<div className="brand-gradient-bg flex size-9 shrink-0 items-center justify-center rounded-full">
<Download className="size-4 text-white" />
</div>
<div className="min-w-0 space-y-1.5">
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
Install the Copilot CLI
</span>
<code className="block truncate rounded-md bg-[var(--color-surface-1)] px-2 py-1 font-mono text-[11px] text-[var(--color-text-secondary)]">
{recommended.command}
</code>
<span className="block text-[11px] text-[var(--color-text-muted)]">
View full instructions for {info.displayName}
</span>
</div>
</button>
);
}
export function WelcomePane({
hasProjects,
connectionStatus,
@@ -165,7 +195,10 @@ export function WelcomePane({
{/* Action cards */}
<motion.div {...fadeUp(isFirstRun && !allDone ? 0.2 : 0.16)} className="flex w-full flex-col gap-2.5">
{/* Primary CTA adapts to state */}
{!isConnected && (
{connectionStatus === 'copilot-cli-missing' && (
<CliMissingCard onOpenSettings={onOpenSettings} />
)}
{!isConnected && connectionStatus !== 'copilot-cli-missing' && (
<ActionCard
icon={<Zap className="size-4 text-white" />}
title="Connect GitHub Copilot"
+838
View File
@@ -0,0 +1,838 @@
import { Fragment, useCallback, useMemo, useState } from 'react';
import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Download, Info, Plus, Trash2, Upload, X } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type {
WorkflowDefinition,
WorkflowGraph,
WorkflowNode,
WorkflowNodeConfig,
WorkflowNodeKind,
WorkflowEdge,
AgentNodeConfig,
SubWorkflowConfig,
WorkflowStateScope,
} from '@shared/domain/workflow';
import { validateWorkflowDefinition, isBuilderBasedMode, syncBuilderModeEdgeIterations } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
import { ToggleSwitch } from '@renderer/components/ui';
import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
import { ExportDropdown, ExportModal, ImportModal } from './workflow/WorkflowExportImportPanel';
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
onChange: (workflow: WorkflowDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
onExportWorkflow?: (format: 'yaml' | 'mermaid' | 'dot') => Promise<{ content: string }>;
onImportWorkflow?: (content: string, format: 'yaml' | 'json') => Promise<WorkflowDefinition>;
}
interface WorkflowBreadcrumb {
workflow: WorkflowDefinition;
nodeId: string;
nodeLabel: string;
/** true when drilling into a referenced (not inline) workflow — view-only */
readOnly: boolean;
}
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const baseClasses =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${baseClasses} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={baseClasses}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
/* ── Default configs for new nodes ─────────────────────────── */
function defaultConfigForKind(kind: WorkflowNodeKind): WorkflowNodeConfig {
switch (kind) {
case 'start':
return { kind: 'start' };
case 'end':
return { kind: 'end' };
case 'agent':
return {
kind: 'agent',
id: createId('agent'),
name: 'New Agent',
description: '',
instructions: '',
model: 'gpt-5.4',
reasoningEffort: 'high',
};
case 'invoke-function':
return { kind: 'invoke-function', functionName: '', arguments: {} };
case 'sub-workflow':
return { kind: 'sub-workflow' };
case 'request-port':
return { kind: 'request-port', portId: '', requestType: '', responseType: '' };
}
}
function defaultLabelForKind(kind: WorkflowNodeKind): string {
switch (kind) {
case 'start':
return 'Start';
case 'end':
return 'End';
case 'agent':
return 'New Agent';
case 'invoke-function':
return 'Function Tool';
case 'sub-workflow':
return 'Sub-Workflow';
case 'request-port':
return 'Request Port';
}
}
/* ── Minimal inline workflow factory ────────────────────────── */
function createMinimalInlineWorkflow(): WorkflowDefinition {
const now = new Date().toISOString();
return {
id: createId('inline-wf'),
name: 'Inline Workflow',
description: '',
graph: {
nodes: [
{ id: createId('wf-start'), kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: createId('wf-end'), kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
],
edges: [],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
},
createdAt: now,
updatedAt: now,
};
}
/* ── Main editor ───────────────────────────────────────────── */
export function WorkflowEditor({
availableModels,
workflow,
workflows,
onChange,
onDelete,
onSave,
onBack,
onExportWorkflow,
onImportWorkflow,
}: WorkflowEditorProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<WorkflowBreadcrumb[]>([]);
const [showExportDropdown, setShowExportDropdown] = useState(false);
const [exportResult, setExportResult] = useState<{ format: 'yaml' | 'mermaid' | 'dot'; content: string } | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
/* ── Active workflow resolution ──────────────────────────── */
const activeWorkflow = useMemo(() => {
if (breadcrumbs.length === 0) return workflow;
return breadcrumbs[breadcrumbs.length - 1].workflow;
}, [workflow, breadcrumbs]);
const isReadOnly = breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1].readOnly;
const issues = validateWorkflowDefinition(activeWorkflow);
const disabledPaletteKinds = useMemo(() => {
const disabled = new Set<WorkflowNodeKind>();
if (activeWorkflow.graph.nodes.some((n) => n.kind === 'start')) disabled.add('start');
if (activeWorkflow.graph.nodes.some((n) => n.kind === 'end')) disabled.add('end');
return disabled;
}, [activeWorkflow.graph.nodes]);
/* ── Change propagation ─────────────────────────────────── */
function propagateActiveChange(updatedActive: WorkflowDefinition) {
if (breadcrumbs.length === 0) {
onChange(updatedActive);
return;
}
// Walk back up the breadcrumb stack, embedding each level's workflow
// into the parent's sub-workflow node config.
let child = updatedActive;
const newCrumbs = [...breadcrumbs];
newCrumbs[newCrumbs.length - 1] = { ...newCrumbs[newCrumbs.length - 1], workflow: child };
for (let i = newCrumbs.length - 1; i >= 0; i--) {
const crumb = newCrumbs[i];
const parent = i === 0 ? workflow : newCrumbs[i - 1].workflow;
const updatedParent: WorkflowDefinition = {
...parent,
graph: {
...parent.graph,
nodes: parent.graph.nodes.map((n) => {
if (n.id !== crumb.nodeId) return n;
return {
...n,
config: { kind: 'sub-workflow' as const, inlineWorkflow: child } satisfies SubWorkflowConfig,
};
}),
},
};
child = updatedParent;
if (i > 0) {
newCrumbs[i - 1] = { ...newCrumbs[i - 1], workflow: updatedParent };
}
}
setBreadcrumbs(newCrumbs);
onChange(child);
}
function emitChange(next: WorkflowDefinition) {
propagateActiveChange(next);
}
function emitGraphChange(graph: WorkflowGraph) {
propagateActiveChange({ ...activeWorkflow, graph });
}
function handleAddNode(kind: WorkflowNodeKind) {
const nodeId = createId(`wf-${kind}`);
const newNode: WorkflowNode = {
id: nodeId,
kind,
label: defaultLabelForKind(kind),
position: { x: 300, y: 200 },
config: defaultConfigForKind(kind),
};
emitGraphChange({
...activeWorkflow.graph,
nodes: [...activeWorkflow.graph.nodes, newNode],
});
setSelectedNodeId(nodeId);
setSelectedEdgeId(null);
}
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
emitGraphChange({
...activeWorkflow.graph,
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
});
}
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
emitGraphChange({
...activeWorkflow.graph,
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
});
}
function handleNodeRemove(nodeId: string) {
const node = activeWorkflow.graph.nodes.find((n) => n.id === nodeId);
if (!node || node.kind === 'start' || node.kind === 'end') {
return;
}
emitGraphChange({
nodes: activeWorkflow.graph.nodes.filter((n) => n.id !== nodeId),
edges: activeWorkflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
});
setSelectedNodeId(null);
}
function handleEdgeChange(edgeId: string, patch: Partial<WorkflowEdge>) {
emitGraphChange({
...activeWorkflow.graph,
edges: activeWorkflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
});
}
function handleEdgeRemove(edgeId: string) {
emitGraphChange({
...activeWorkflow.graph,
edges: activeWorkflow.graph.edges.filter((e) => e.id !== edgeId),
});
setSelectedEdgeId(null);
}
/* ── Drill-down ─────────────────────────────────────────── */
const handleDrillIntoSubWorkflow = useCallback(
(node: WorkflowNode) => {
if (node.config.kind !== 'sub-workflow') return;
const config = node.config as SubWorkflowConfig;
if (config.workflowId) {
// Reference mode — find the referenced workflow and show it read-only
const ref = workflows.find((wf) => wf.id === config.workflowId);
if (!ref) return;
setBreadcrumbs((prev) => [
...prev,
{ workflow: ref, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: true },
]);
} else {
// Inline mode — create a minimal workflow if needed, then drill in
let inlineWf = config.inlineWorkflow;
if (!inlineWf) {
inlineWf = createMinimalInlineWorkflow();
// Persist the new inline workflow into the node
handleNodeConfigChange(node.id, { kind: 'sub-workflow', inlineWorkflow: inlineWf });
}
setBreadcrumbs((prev) => [
...prev,
{ workflow: inlineWf, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: false },
]);
}
setSelectedNodeId(null);
setSelectedEdgeId(null);
},
[workflows, handleNodeConfigChange],
);
/* Breadcrumb sync: when the top-level workflow changes externally,
re-resolve the breadcrumb stack from the current workflow to keep
inline sub-workflows in sync. */
// Kept simple: if breadcrumbs exist and the innermost is inline,
// re-resolve the active workflow from the current top-level workflow
// so external saves don't desync. Referenced (read-only) crumbs
// already point to a stable workflow object.
useMemo(() => {
if (breadcrumbs.length === 0) return;
let current: WorkflowDefinition = workflow;
const synced: WorkflowBreadcrumb[] = [];
for (const crumb of breadcrumbs) {
if (crumb.readOnly) {
synced.push(crumb);
continue;
}
const parentNode = current.graph.nodes.find((n) => n.id === crumb.nodeId);
if (
!parentNode ||
parentNode.config.kind !== 'sub-workflow' ||
!(parentNode.config as SubWorkflowConfig).inlineWorkflow
) {
// Breadcrumb target no longer exists — pop remaining crumbs
break;
}
const resolved = (parentNode.config as SubWorkflowConfig).inlineWorkflow!;
synced.push({ ...crumb, workflow: resolved });
current = resolved;
}
if (synced.length !== breadcrumbs.length || synced.some((s, i) => s.workflow !== breadcrumbs[i].workflow)) {
setBreadcrumbs(synced);
}
}, [workflow]); // intentionally excluding breadcrumbs to avoid loops
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
<div className="flex items-center gap-3">
<button
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
{workflow.name || 'Untitled workflow'}
</h3>
<p className="text-[12px] text-[var(--color-text-muted)]">Workflow Designer</p>
</div>
</div>
<div className="no-drag flex items-center gap-2">
{onExportWorkflow && (
<div className="relative">
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={() => setShowExportDropdown((prev) => !prev)}
type="button"
aria-expanded={showExportDropdown}
aria-haspopup="listbox"
>
<Download className="size-3.5" />
Export
</button>
{showExportDropdown && (
<ExportDropdown
onSelectFormat={async (format) => {
const result = await onExportWorkflow(format);
setExportResult({ format, content: result.content });
}}
onClose={() => setShowExportDropdown(false)}
/>
)}
</div>
)}
{onImportWorkflow && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={() => setShowImportModal(true)}
type="button"
>
<Upload className="size-3.5" />
Import
</button>
)}
{onDelete && (
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
onClick={onDelete}
type="button"
>
<Trash2 className="size-3.5" />
Delete
</button>
)}
<button
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
onClick={onSave}
type="button"
>
Save
</button>
</div>
</div>
{/* Breadcrumb bar */}
{breadcrumbs.length > 0 && (
<div className="flex items-center gap-1 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
<button
className="text-[12px] text-[var(--color-accent)] hover:underline"
onClick={() => { setBreadcrumbs([]); setSelectedNodeId(null); setSelectedEdgeId(null); }}
type="button"
>
{workflow.name || 'Untitled'}
</button>
{breadcrumbs.map((crumb, index) => (
<Fragment key={`${crumb.nodeId}-${index}`}>
<ChevronRight className="size-3 text-[var(--color-text-muted)]" />
<button
className={`text-[12px] ${
index === breadcrumbs.length - 1
? 'font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-accent)] hover:underline'
}`}
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, index + 1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
type="button"
>
{crumb.nodeLabel}
</button>
</Fragment>
))}
</div>
)}
{/* Read-only banner for referenced sub-workflows */}
{isReadOnly && (
<div className="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
<Info className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[12px] text-[var(--color-text-muted)]">
This is a referenced workflow. Open it separately to edit.
</span>
<button
className="ml-auto text-[12px] text-[var(--color-accent)] hover:underline"
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, -1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
type="button"
>
Go back
</button>
</div>
)}
{/* Body — palette + canvas + inspector */}
<div className="flex min-h-0 flex-1">
{/* Left palette */}
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowNodePalette disabledKinds={disabledPaletteKinds} onAddNode={handleAddNode} />
</div>
{/* Center column: validation + canvas + settings */}
<div className="min-w-0 flex-1 overflow-y-auto">
{/* Validation banner */}
<div className="px-5 pt-4">
{issues.length > 0 ? (
<div className="space-y-1.5">
{issues.map((issue, i) => (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
issue.level === 'error'
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
}`}
key={`${issue.field ?? 'v'}-${i}`}
>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
{issue.message}
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
<CheckCircle className="size-3.5" />
Workflow is valid
</div>
)}
</div>
{/* Graph canvas — guaranteed minimum height so settings never collapse it */}
<div className="min-h-[360px] px-5 pb-2 pt-4" style={{ height: 'clamp(360px, 50vh, 100%)' }}>
<WorkflowGraphCanvas
availableModels={availableModels}
onEdgeSelect={setSelectedEdgeId}
onGraphChange={emitGraphChange}
onNodeSelect={setSelectedNodeId}
selectedNodeId={selectedNodeId}
workflow={activeWorkflow}
workflows={workflows}
/>
</div>
{/* Settings below canvas */}
{breadcrumbs.length === 0 && (
<WorkflowSettingsPanel workflow={workflow} onChange={emitChange} />
)}
</div>
{/* Right inspector */}
<div className="w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowGraphInspector
availableModels={availableModels}
onDrillIntoSubWorkflow={handleDrillIntoSubWorkflow}
onEdgeChange={handleEdgeChange}
onEdgeRemove={handleEdgeRemove}
onNodeChange={handleNodeChange}
onNodeConfigChange={handleNodeConfigChange}
onNodeRemove={handleNodeRemove}
selectedEdgeId={selectedEdgeId}
selectedNodeId={selectedNodeId}
validationIssues={issues}
workflow={activeWorkflow}
workflows={workflows}
/>
</div>
</div>
{exportResult && (
<ExportModal
format={exportResult.format}
content={exportResult.content}
onClose={() => setExportResult(null)}
/>
)}
{showImportModal && onImportWorkflow && (
<ImportModal
onImport={async (content, format) => {
const imported = await onImportWorkflow(content, format);
onChange(imported);
return imported;
}}
onClose={() => setShowImportModal(false)}
/>
)}
</div>
);
}
/* ── Settings panel below canvas ───────────────────────────── */
function StateScopeInitialValues({
initialValues,
onChange,
}: {
initialValues: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}) {
const entries = Object.entries(initialValues);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--color-text-muted)]">Initial Values</span>
<button
className="flex size-5 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
onClick={() => {
const key = `key${entries.length + 1}`;
onChange({ ...initialValues, [key]: '' });
}}
title="Add initial value"
type="button"
>
<Plus className="size-3" />
</button>
</div>
{entries.map(([key, value]) => (
<div className="flex items-center gap-1.5" key={key}>
<input
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
const next = { ...initialValues };
const val = next[key];
delete next[key];
next[e.target.value] = val;
onChange(next);
}}
placeholder="key"
value={key}
/>
<input
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
onChange({ ...initialValues, [key]: e.target.value });
}}
placeholder="value"
value={typeof value === 'string' ? value : JSON.stringify(value) ?? ''}
/>
<button
className="flex size-5 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => {
const next = { ...initialValues };
delete next[key];
onChange(next);
}}
title="Remove value"
type="button"
>
<X className="size-2.5" />
</button>
</div>
))}
</div>
);
}
function WorkflowSettingsPanel({
workflow,
onChange,
}: {
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
}) {
return (
<div className="border-t border-[var(--color-border)] px-5 py-4">
<h4 className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Settings
</h4>
{/* Orchestration mode — prominent, above general settings */}
<div className="mb-5">
<OrchestrationModePanel workflow={workflow} onChange={onChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<InputField
label="Name"
onChange={(v) => onChange({ ...workflow, name: v })}
placeholder="Workflow name"
value={workflow.name}
/>
<InputField
label="Description"
onChange={(v) => onChange({ ...workflow, description: v })}
placeholder="What this workflow does"
value={workflow.description}
/>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Execution Mode</span>
<select
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) =>
onChange({
...workflow,
settings: { ...workflow.settings, executionMode: e.target.value as 'off-thread' | 'lockstep' },
})
}
value={workflow.settings.executionMode}
>
<option value="off-thread">Off-thread</option>
<option value="lockstep">Lockstep</option>
</select>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Off-thread executes in the background without blocking. Lockstep waits for each step to finish before proceeding.
</p>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Max Iterations</span>
<input
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50"
min={1}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
const maxIterations = Number.isNaN(raw) ? undefined : raw;
const updated: WorkflowDefinition = {
...workflow,
settings: {
...workflow.settings,
maxIterations,
...(workflow.settings.orchestrationMode === 'group-chat' && maxIterations !== undefined
? {
modeSettings: {
...workflow.settings.modeSettings,
groupChat: {
...(workflow.settings.modeSettings?.groupChat ?? { selectionStrategy: 'round-robin' as const }),
maxRounds: maxIterations,
},
},
}
: {}),
},
};
onChange(
isBuilderBasedMode(workflow.settings.orchestrationMode)
? syncBuilderModeEdgeIterations(updated)
: updated,
);
}}
placeholder="e.g. 5"
type="number"
value={workflow.settings.maxIterations ?? ''}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Safety limit on total step executions to prevent runaway loops.
</p>
</label>
<div className="col-span-2 flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-3">
<div>
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Checkpointing</div>
<p className="text-[12px] text-[var(--color-text-muted)]">Enable state checkpointing between steps</p>
</div>
<button
className="cursor-pointer"
onClick={() =>
onChange({
...workflow,
settings: {
...workflow.settings,
checkpointing: { enabled: !workflow.settings.checkpointing.enabled },
},
})
}
type="button"
>
<ToggleSwitch enabled={workflow.settings.checkpointing.enabled} />
</button>
</div>
{/* State Scopes */}
<div className="col-span-2 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
State Scopes
</span>
<p className="mt-0.5 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Named containers for shared state that persists across workflow steps.
</p>
</div>
<button
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
onClick={() => {
const scopes = [...(workflow.settings.stateScopes ?? []), { name: '', description: '', initialValues: {} }];
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
title="Add state scope"
type="button"
>
<Plus className="size-3.5" />
</button>
</div>
{(workflow.settings.stateScopes ?? []).map((scope, idx) => (
<div
className="space-y-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-3"
key={idx}
>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
const scopes = [...(workflow.settings.stateScopes ?? [])];
scopes[idx] = { ...scopes[idx], name: e.target.value };
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
placeholder="Scope name"
value={scope.name}
/>
<button
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => {
const scopes = (workflow.settings.stateScopes ?? []).filter((_, i) => i !== idx);
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
title="Remove scope"
type="button"
>
<X className="size-3" />
</button>
</div>
<input
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => {
const scopes = [...(workflow.settings.stateScopes ?? [])];
scopes[idx] = { ...scopes[idx], description: e.target.value };
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
placeholder="Description (optional)"
value={scope.description ?? ''}
/>
<StateScopeInitialValues
initialValues={scope.initialValues ?? {}}
onChange={(initialValues) => {
const scopes = [...(workflow.settings.stateScopes ?? [])];
scopes[idx] = { ...scopes[idx], initialValues };
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
}}
/>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,551 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ArrowUpFromLine,
Check,
ChevronRight,
FileCode2,
FileMinus2,
FilePlus2,
GitBranch,
Loader2,
Sparkles,
X,
} from 'lucide-react';
import { getElectronApi } from '@renderer/lib/electronApi';
import type {
ProjectGitCommitMessageSuggestion,
ProjectGitConventionalCommitType,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitFileReference,
ProjectGitWorkingTreeFile,
} from '@shared/domain/project';
/* ── Helpers ───────────────────────────────────────────────── */
function fileBaseName(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
}
function fileDir(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
}
function fileIcon(file: ProjectGitWorkingTreeFile) {
if (file.stagedStatus === 'added' || file.unstagedStatus === 'untracked') {
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
}
if (file.stagedStatus === 'deleted' || file.unstagedStatus === 'deleted') {
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
}
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
function statusLabel(file: ProjectGitWorkingTreeFile): string {
return file.stagedStatus ?? file.unstagedStatus ?? 'modified';
}
const CONVENTIONAL_TYPES: { value: ProjectGitConventionalCommitType; label: string }[] = [
{ value: 'feat', label: 'feat' },
{ value: 'fix', label: 'fix' },
{ value: 'refactor', label: 'refactor' },
{ value: 'docs', label: 'docs' },
{ value: 'test', label: 'test' },
{ value: 'chore', label: 'chore' },
];
/* ── Diff line renderer ────────────────────────────────────── */
function DiffLine({ line }: { line: string }) {
let textClass = 'text-[var(--color-text-secondary)]';
let bgClass = '';
if (line.startsWith('+') && !line.startsWith('+++')) {
textClass = 'text-[var(--color-status-success)]';
bgClass = 'bg-[var(--color-status-success)]/[0.06]';
} else if (line.startsWith('-') && !line.startsWith('---')) {
textClass = 'text-[var(--color-status-error)]';
bgClass = 'bg-[var(--color-status-error)]/[0.06]';
} else if (line.startsWith('@@')) {
textClass = 'text-[var(--color-accent-sky)]';
} else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
textClass = 'text-[var(--color-text-muted)]';
}
return <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
}
/* ── File row with staging checkbox ────────────────────────── */
function CommitFileRow({
file,
isStaged,
onToggle,
onPreview,
preview,
previewExpanded,
}: {
file: ProjectGitWorkingTreeFile;
isStaged: boolean;
onToggle: () => void;
onPreview: () => void;
preview?: ProjectGitDiffPreview;
previewExpanded: boolean;
}) {
const dir = fileDir(file.path);
const base = fileBaseName(file.path);
const hasPreview = !!preview?.diff || !!preview?.newFileContents;
return (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<div className="flex items-center gap-1 px-2.5 py-[5px] text-[10px]">
{/* Staging checkbox */}
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
isStaged
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={onToggle}
type="button"
aria-label={`${isStaged ? 'Unstage' : 'Stage'} ${file.path}`}
aria-pressed={isStaged}
>
{isStaged && <Check className="size-2" />}
</button>
{/* File path + expand */}
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:text-[var(--color-text-primary)]"
onClick={onPreview}
type="button"
aria-expanded={previewExpanded}
>
{hasPreview || previewExpanded ? (
<ChevronRight
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${previewExpanded ? 'rotate-90' : ''}`}
/>
) : (
<span className="w-2.5 shrink-0" />
)}
{fileIcon(file)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{statusLabel(file)}
</span>
</button>
</div>
{/* Diff preview */}
{previewExpanded && preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-52 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
{preview.diff
? preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
: preview.newFileContents
? preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: preview.isBinary
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
: <div className="text-[var(--color-text-muted)] italic">Loading</div>}
</pre>
</div>
)}
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface CommitComposerProps {
projectId: string;
sessionId: string;
runId?: string;
onClose: () => void;
}
export function CommitComposer({
projectId,
sessionId,
runId,
onClose,
}: CommitComposerProps) {
const api = getElectronApi();
const [details, setDetails] = useState<ProjectGitDetails>();
const [loading, setLoading] = useState(true);
const [commitMessage, setCommitMessage] = useState('');
const [commitType, setCommitType] = useState<ProjectGitConventionalCommitType>('feat');
const [stagedPaths, setStagedPaths] = useState<Set<string>>(new Set());
const [previews, setPreviews] = useState<Record<string, ProjectGitDiffPreview>>({});
const [expandedPath, setExpandedPath] = useState<string>();
const [committing, setCommitting] = useState(false);
const [pushAfterCommit, setPushAfterCommit] = useState(false);
const [commitError, setCommitError] = useState<string>();
const [commitSuccess, setCommitSuccess] = useState(false);
// Load git details on mount
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const result = await api.getProjectGitDetails({ projectId });
if (!cancelled) {
setDetails(result);
// Pre-stage files that are already in the index
if (result.workingTree) {
const preStaged = new Set<string>();
for (const file of result.workingTree.files) {
if (file.stagedStatus && file.stagedStatus !== 'unmerged') {
preStaged.add(file.path);
}
}
setStagedPaths(preStaged);
}
}
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => { cancelled = true; };
}, [api, projectId]);
// Load commit message suggestion
useEffect(() => {
let cancelled = false;
async function suggest() {
try {
const suggestion = await api.suggestProjectGitCommitMessage({
sessionId,
runId,
conventionalType: commitType,
});
if (!cancelled && suggestion) {
setCommitMessage(suggestion.message);
setCommitType(suggestion.type);
}
} catch {
// Suggestion is best-effort
}
}
if (!commitMessage) {
void suggest();
}
return () => { cancelled = true; };
}, [api, sessionId, runId]); // Deliberately omitting commitType/commitMessage to avoid re-triggering
const files = useMemo(
() => details?.workingTree?.files ?? [],
[details?.workingTree?.files],
);
const stagedFiles = useMemo(
() => files.filter((f) => stagedPaths.has(f.path)),
[files, stagedPaths],
);
const toggleStaged = useCallback((path: string) => {
setStagedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const toggleAll = useCallback(() => {
if (stagedPaths.size === files.length) {
setStagedPaths(new Set());
} else {
setStagedPaths(new Set(files.map((f) => f.path)));
}
}, [files, stagedPaths.size]);
const handlePreview = useCallback(async (file: ProjectGitWorkingTreeFile) => {
if (expandedPath === file.path) {
setExpandedPath(undefined);
return;
}
setExpandedPath(file.path);
if (!previews[file.path]) {
try {
const preview = await api.getProjectGitFilePreview({
projectId,
file: { path: file.path, previousPath: file.previousPath },
});
if (preview) {
setPreviews((prev) => ({ ...prev, [file.path]: preview }));
}
} catch {
// Preview is best-effort
}
}
}, [api, expandedPath, previews, projectId]);
const handleSuggestMessage = useCallback(async () => {
try {
const suggestion = await api.suggestProjectGitCommitMessage({
sessionId,
runId,
conventionalType: commitType,
});
if (suggestion) {
setCommitMessage(suggestion.message);
setCommitType(suggestion.type);
}
} catch {
// Best-effort
}
}, [api, sessionId, runId, commitType]);
const handleCommit = useCallback(async () => {
if (!commitMessage.trim() || stagedFiles.length === 0) return;
setCommitting(true);
setCommitError(undefined);
try {
const filesToCommit: ProjectGitFileReference[] = stagedFiles.map((f) => ({
path: f.path,
previousPath: f.previousPath,
}));
await api.commitProjectGitChanges({
projectId,
message: commitMessage.trim(),
files: filesToCommit,
push: pushAfterCommit,
});
setCommitSuccess(true);
setTimeout(() => onClose(), 1200);
} catch (error) {
setCommitError(error instanceof Error ? error.message : String(error));
} finally {
setCommitting(false);
}
}, [api, commitMessage, onClose, projectId, pushAfterCommit, stagedFiles]);
// Keyboard: Escape to close
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-stretch justify-end"
role="dialog"
aria-modal="true"
aria-labelledby="commit-composer-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-[2px]"
onClick={onClose}
/>
{/* Panel */}
<div className="relative z-10 flex w-[420px] max-w-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
{/* Header */}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-4 py-3">
<GitBranch className="size-4 text-[var(--color-accent-sky)]" />
<h2 id="commit-composer-title" className="font-display text-sm font-semibold text-[var(--color-text-primary)]">
Commit Changes
</h2>
{details?.context.branch && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
on {details.context.branch}
</span>
)}
<div className="flex-1" />
<button
className="rounded-md p-1 text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
onClick={onClose}
type="button"
aria-label="Close commit composer"
>
<X className="size-4" />
</button>
</div>
{/* Loading */}
{loading && (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-5 animate-spin text-[var(--color-text-muted)]" aria-label="Loading git details" />
</div>
)}
{/* Success state */}
{commitSuccess && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
<div className="flex size-12 items-center justify-center rounded-full bg-[var(--color-status-success)]/10">
<Check className="size-6 text-[var(--color-status-success)]" />
</div>
<p className="text-sm font-medium text-[var(--color-status-success)]">
Changes committed{pushAfterCommit ? ' and pushed' : ''}
</p>
</div>
)}
{/* Content */}
{!loading && !commitSuccess && (
<>
{/* Commit message */}
<div className="border-b border-[var(--color-border-subtle)] px-4 py-3">
{/* Type selector */}
<div className="mb-2 flex items-center gap-1">
{CONVENTIONAL_TYPES.map((ct) => (
<button
key={ct.value}
className={`rounded-md px-2 py-0.5 text-[10px] font-medium transition-colors duration-100 ${
commitType === ct.value
? 'bg-[var(--color-accent)]/15 text-[var(--color-text-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => setCommitType(ct.value)}
type="button"
>
{ct.label}
</button>
))}
<div className="flex-1" />
<button
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-accent)]"
onClick={() => void handleSuggestMessage()}
type="button"
title="Suggest commit message"
>
<Sparkles className="size-3" />
Suggest
</button>
</div>
<textarea
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface-0)] px-3 py-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
placeholder="Commit message…"
rows={3}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</div>
{/* File list */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Select all header */}
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] px-2.5 py-1.5">
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
stagedPaths.size === files.length && files.length > 0
? 'border-[var(--color-status-success)] bg-[var(--color-status-success)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={toggleAll}
type="button"
aria-label={stagedPaths.size === files.length ? 'Unstage all' : 'Stage all'}
>
{stagedPaths.size === files.length && files.length > 0 && <Check className="size-2" />}
</button>
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">
{stagedPaths.size} of {files.length} staged
</span>
</div>
{files.length === 0 ? (
<p className="px-4 py-6 text-center text-[11px] text-[var(--color-text-muted)]">
Working tree is clean nothing to commit.
</p>
) : (
files.map((file) => (
<CommitFileRow
file={file}
isStaged={stagedPaths.has(file.path)}
key={file.path}
onPreview={() => void handlePreview(file)}
onToggle={() => toggleStaged(file.path)}
preview={previews[file.path]}
previewExpanded={expandedPath === file.path}
/>
))
)}
</div>
{/* Error */}
{commitError && (
<div className="border-t border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 px-4 py-2 text-[10px] text-[var(--color-status-error)]" role="alert">
{commitError}
</div>
)}
{/* Footer actions */}
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-4 py-3">
{/* Push toggle */}
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<input
type="checkbox"
checked={pushAfterCommit}
onChange={(e) => setPushAfterCommit(e.target.checked)}
className="accent-[var(--color-accent)]"
/>
Push after commit
</label>
<div className="flex-1" />
<button
className="rounded-md px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-[11px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={committing || !commitMessage.trim() || stagedFiles.length === 0}
onClick={() => void handleCommit()}
type="button"
>
{committing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ArrowUpFromLine className="size-3" />
)}
{committing ? 'Committing…' : pushAfterCommit ? 'Commit & Push' : 'Commit'}
</button>
</div>
</>
)}
</div>
</div>
);
}
+32 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
import { ChevronDown, ChevronRight, GitBranch, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { PopoverToggleRow } from '@renderer/components/ui';
@@ -7,7 +7,7 @@ import { useClickOutside } from '@renderer/hooks/useClickOutside';
import type { ApprovalToolDefinition, LspProfileDefinition, McpServerDefinition, SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
import { groupApprovalToolsByProvider, type ApprovalToolGroup } from '@shared/domain/tooling';
import { findModel, inferProvider, providerMeta, type ModelDefinition } from '@shared/domain/models';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/workflow';
import { RotateCcw, Server, ShieldCheck } from 'lucide-react';
/* ── Tier badge ────────────────────────────────────────────── */
@@ -556,7 +556,7 @@ export function InlineApprovalPill({
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
{/* Header: session override / pattern defaults */}
{/* Header: session override / workflow defaults */}
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
<div className="flex items-center gap-2 px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
@@ -757,3 +757,32 @@ export function InlineTerminalPill({
</button>
);
}
/* ── InlineGitPill ─────────────────────────────────────────── */
export function InlineGitPill({
isDirty,
isOpen,
onToggle,
}: {
isDirty: boolean;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<button
aria-pressed={isOpen}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium transition-all duration-200 ${
isOpen
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] hover:bg-[var(--color-accent)]/20'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
}`}
onClick={onToggle}
type="button"
>
{isDirty && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-warning)]" />}
<GitBranch className="size-3" />
<span>Git</span>
</button>
);
}
+127 -35
View File
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import { ArrowUp, FileText, X } from 'lucide-react';
import { useClickOutside } from '@renderer/hooks/useClickOutside';
import type { ProjectPromptFile, ProjectPromptVariable } from '@shared/domain/projectCustomization';
import type { ProjectPromptFile, ProjectPromptInvocation, ProjectPromptVariable } from '@shared/domain/projectCustomization';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):[^}]+\}/g;
@@ -12,14 +12,52 @@ function resolvePromptTemplate(template: string, values: Record<string, string>)
});
}
function buildPromptInvocation(prompt: ProjectPromptFile, resolvedTemplate: string): ProjectPromptInvocation {
const invocation: ProjectPromptInvocation = {
id: prompt.id,
name: prompt.name,
sourcePath: prompt.sourcePath,
resolvedPrompt: resolvedTemplate,
};
if (prompt.description) invocation.description = prompt.description;
if (prompt.agent) invocation.agent = prompt.agent;
if (prompt.model) invocation.model = prompt.model;
if (prompt.tools?.length) invocation.tools = prompt.tools;
return invocation;
}
/** Returns a human-friendly display label for source paths, shortening ancestor-relative segments. */
function formatPromptSourcePath(sourcePath: string): string {
const normalized = sourcePath.replace(/\\/g, '/');
const ancestorPrefix = /^(\.\.\/)+(\.github|\.claude)\//;
if (ancestorPrefix.test(normalized)) {
const segments = normalized.split('/').filter((s) => s !== '..');
return `${segments.join('/')}`;
}
return normalized;
}
export interface ArmedPrompt {
prompt: ProjectPromptFile;
invocation: ProjectPromptInvocation;
}
export function InlinePromptPill({
promptFiles,
disabled,
armedPrompt,
onArm,
onDisarm,
onSubmit,
}: {
promptFiles: ReadonlyArray<ProjectPromptFile>;
disabled: boolean;
onSubmit: (resolvedContent: string) => void;
armedPrompt?: ArmedPrompt | null;
onArm?: (armed: ArmedPrompt) => void;
onDisarm?: () => void;
onSubmit: (invocation: ProjectPromptInvocation) => void;
}) {
const [open, setOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = useState<ProjectPromptFile | null>(null);
@@ -32,23 +70,32 @@ export function InlinePromptPill({
setVariableValues({});
}, []);
const armOrSubmit = useCallback((prompt: ProjectPromptFile, resolvedTemplate: string) => {
const invocation = buildPromptInvocation(prompt, resolvedTemplate);
if (prompt.argumentHint && onArm) {
onArm({ prompt, invocation });
handleClose();
} else {
onSubmit(invocation);
handleClose();
}
}, [onArm, onSubmit, handleClose]);
const handleSelectPrompt = useCallback((prompt: ProjectPromptFile) => {
if (prompt.variables.length === 0) {
onSubmit(prompt.template.trim());
handleClose();
armOrSubmit(prompt, prompt.template.trim());
} else {
setSelectedPrompt(prompt);
setVariableValues({});
}
}, [onSubmit, handleClose]);
}, [armOrSubmit]);
const handleSubmitWithVariables = useCallback(() => {
if (!selectedPrompt) return;
const resolved = resolvePromptTemplate(selectedPrompt.template, variableValues).trim();
if (!resolved) return;
onSubmit(resolved);
handleClose();
}, [selectedPrompt, variableValues, onSubmit, handleClose]);
armOrSubmit(selectedPrompt, resolved);
}, [selectedPrompt, variableValues, armOrSubmit]);
const handleVariableChange = useCallback((name: string, value: string) => {
setVariableValues((prev) => ({ ...prev, [name]: value }));
@@ -63,18 +110,31 @@ export function InlinePromptPill({
return (
<div className="relative" ref={ref}>
<button
aria-expanded={open}
aria-haspopup="listbox"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<FileText className="size-3" />
Prompts
<span className="text-[var(--color-text-muted)]">({promptFiles.length})</span>
</button>
{armedPrompt ? (
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-status-success)]/10 px-2.5 py-1 text-[11px] font-medium text-[var(--color-status-success)] transition-all duration-200 hover:bg-[var(--color-status-success)]/20"
onClick={() => onDisarm?.()}
type="button"
aria-label={`Disarm prompt: ${armedPrompt.prompt.name}`}
>
<FileText className="size-3" />
<span className="max-w-[120px] truncate">{armedPrompt.prompt.name}</span>
<X className="size-3 opacity-60" />
</button>
) : (
<button
aria-expanded={open}
aria-haspopup="listbox"
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<FileText className="size-3" />
Prompts
<span className="text-[var(--color-text-muted)]">({promptFiles.length})</span>
</button>
)}
{open && !disabled && (
<div
@@ -127,24 +187,49 @@ function PromptList({
>
<FileText className="mt-0.5 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
</span>
{prompt.agent && (
<span className="shrink-0 rounded bg-[var(--color-accent-sky)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-sky)]">
{prompt.agent}
</span>
)}
{prompt.model && (
<span className="shrink-0 rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</span>
)}
</div>
{prompt.description && (
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">
{prompt.description}
</div>
)}
{prompt.variables.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{prompt.variables.map((v) => (
<span
key={v.name}
className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
>
{v.name}
</span>
))}
<div className="mt-1 flex flex-wrap gap-1">
{prompt.tools && prompt.tools.length > 0 && (
<span className="rounded bg-[var(--color-status-warning)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-status-warning)]">
{prompt.tools.length} tool{prompt.tools.length === 1 ? '' : 's'}
</span>
)}
{prompt.argumentHint && (
<span className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] italic text-[var(--color-text-muted)]">
hint: {prompt.argumentHint}
</span>
)}
{prompt.variables.map((v) => (
<span
key={v.name}
className="rounded bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
>
{v.name}
</span>
))}
</div>
{prompt.sourcePath.startsWith('..') && (
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{formatPromptSourcePath(prompt.sourcePath)}
</div>
)}
</div>
@@ -181,8 +266,15 @@ function PromptVariableForm({
<X className="size-3" />
</button>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{prompt.name}
</span>
{prompt.model && (
<span className="shrink-0 rounded bg-[var(--color-accent-purple)]/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-accent-purple)]">
{prompt.model}
</span>
)}
</div>
{prompt.description && (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{prompt.description}</div>
@@ -213,7 +305,7 @@ function PromptVariableForm({
type="button"
>
<ArrowUp className="size-3.5" />
Send prompt
{prompt.argumentHint ? 'Arm prompt' : 'Send prompt'}
</button>
</div>
);
@@ -8,7 +8,6 @@ import {
FileText,
Globe,
Server,
Terminal,
} from 'lucide-react';
import type { PermissionDetail } from '@shared/contracts/sidecar';
@@ -61,6 +60,37 @@ export function permissionDetailSummary(detail: PermissionDetail): string | unde
}
}
/* ── Display helpers ─────────────────────────────────────────── */
/** Recursively parse string values that contain JSON objects or arrays (display-time only). */
function deepParseJsonStrings(value: unknown): unknown {
if (typeof value === 'string') {
const trimmed = value.trim();
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
return deepParseJsonStrings(JSON.parse(trimmed));
} catch {
return value;
}
}
return value;
}
if (Array.isArray(value)) {
return value.map(deepParseJsonStrings);
}
if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
result[k] = deepParseJsonStrings(v);
}
return result;
}
return value;
}
/* ── Kind-specific renderers ────────────────────────────────── */
function ShellDetail({ detail }: { detail: PermissionDetail }) {
@@ -134,7 +164,7 @@ function McpDetail({ detail }: { detail: PermissionDetail }) {
)}
</div>
{detail.args && Object.keys(detail.args).length > 0 && (
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
)}
</div>
);
@@ -185,7 +215,7 @@ function CustomToolDetail({ detail }: { detail: PermissionDetail }) {
<p className="text-[11px] text-[var(--color-text-secondary)]">{detail.toolDescription}</p>
)}
{detail.args && Object.keys(detail.args).length > 0 && (
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
)}
</div>
);
@@ -201,13 +231,13 @@ function HookDetail({ detail }: { detail: PermissionDetail }) {
</div>
)}
{detail.args && Object.keys(detail.args).length > 0 && (
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
<CollapsibleCode label="Arguments" text={JSON.stringify(deepParseJsonStrings(detail.args), null, 2)} />
)}
</div>
);
}
/* ── Shared primitives ──────────────────────────────────────── */
/* ── Shared primitives──────────────────────────────────────── */
function IntentionLine({ text }: { text: string }) {
return <p className="text-[11px] italic text-[var(--color-text-secondary)]">{text}</p>;
@@ -242,6 +272,47 @@ function DiffBlock({ text }: { text: string }) {
);
}
/* ── JSON syntax highlighting ───────────────────────────────── */
const jsonTokenPattern =
/("(?:[^"\\]|\\.)*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],])/g;
function JsonHighlighted({ json }: { json: string }) {
const elements: React.ReactNode[] = [];
let lastIndex = 0;
let key = 0;
for (const match of json.matchAll(jsonTokenPattern)) {
const idx = match.index ?? 0;
if (idx > lastIndex) elements.push(json.slice(lastIndex, idx));
if (match[1] && match[2]) {
// Object key + colon
elements.push(
<span key={key++} className="text-[var(--color-text-accent)]">{match[1]}</span>,
<span key={key++} className="text-[var(--color-text-muted)]">{match[2]}</span>,
);
} else if (match[1]) {
// String value
elements.push(<span key={key++} className="text-[var(--color-status-success)]">{match[1]}</span>);
} else if (match[3]) {
// true / false / null
elements.push(<span key={key++} className="text-[var(--color-accent-sky)]">{match[3]}</span>);
} else if (match[4]) {
// Number
elements.push(<span key={key++} className="text-[var(--color-accent-sky)]">{match[4]}</span>);
} else if (match[5]) {
// Structural punctuation
elements.push(<span key={key++} className="text-[var(--color-text-muted)]">{match[5]}</span>);
}
lastIndex = idx + match[0].length;
}
if (lastIndex < json.length) elements.push(json.slice(lastIndex));
return <>{elements}</>;
}
function CollapsibleCode({
label,
text,
@@ -254,6 +325,7 @@ function CollapsibleCode({
defaultExpanded?: boolean;
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const isJson = text.trimStart().startsWith('{') || text.trimStart().startsWith('[');
return (
<div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
@@ -272,7 +344,7 @@ function CollapsibleCode({
<div className="border-t border-[var(--color-border-subtle)] px-2.5 py-1.5">
{children ?? (
<pre className="max-h-48 overflow-auto font-mono text-[10px] leading-relaxed text-[var(--color-text-primary)]">
{text}
{isJson ? <JsonHighlighted json={text} /> : text}
</pre>
)}
</div>
@@ -0,0 +1,420 @@
import { useCallback, useMemo, useState } from 'react';
import {
AlertTriangle,
ArrowDownToLine,
Check,
ChevronRight,
FileCode2,
FileMinus2,
FilePlus2,
GitBranch,
Loader2,
RotateCcw,
Trash2,
} from 'lucide-react';
import type {
ProjectGitFileReference,
ProjectGitRunChangedFile,
ProjectGitRunChangeSummary,
} from '@shared/domain/project';
/* ── Helpers ───────────────────────────────────────────────── */
function fileBaseName(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
}
function fileDir(path: string): string {
const normalized = path.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
return lastSlash > 0 ? normalized.slice(0, lastSlash + 1) : '';
}
function kindLabel(kind: ProjectGitRunChangedFile['kind']): string {
switch (kind) {
case 'added': return 'added';
case 'modified': return 'modified';
case 'deleted': return 'deleted';
case 'renamed': return 'renamed';
case 'copied': return 'copied';
case 'type-changed': return 'type changed';
case 'unmerged': return 'conflict';
case 'untracked': return 'new';
case 'cleaned': return 'cleaned';
}
}
function kindIcon(kind: ProjectGitRunChangedFile['kind']) {
switch (kind) {
case 'added':
case 'untracked':
case 'copied':
return <FilePlus2 className="size-3 shrink-0 text-[var(--color-status-success)]" />;
case 'deleted':
case 'cleaned':
return <FileMinus2 className="size-3 shrink-0 text-[var(--color-status-error)]" />;
default:
return <FileCode2 className="size-3 shrink-0 text-[var(--color-accent-sky)]" />;
}
}
function originBadge(origin: ProjectGitRunChangedFile['origin']) {
if (origin === 'pre-existing') {
return (
<span className="rounded px-1 py-px text-[7px] font-semibold uppercase tracking-wider bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]">
pre-existing
</span>
);
}
return null;
}
/* ── Mini diff-stats bar ───────────────────────────────────── */
function DiffStatsBar({ additions, deletions }: { additions: number; deletions: number }) {
const total = additions + deletions;
if (total === 0) return null;
const blocks = 5;
const addBlocks = Math.max(additions > 0 ? 1 : 0, Math.round((additions / total) * blocks));
const delBlocks = blocks - addBlocks;
return (
<span className="inline-flex gap-px" aria-label={`${additions} additions, ${deletions} deletions`}>
{Array.from({ length: addBlocks }, (_, i) => (
<span key={`a${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-success)]" />
))}
{Array.from({ length: delBlocks }, (_, i) => (
<span key={`d${i}`} className="size-1.5 rounded-[1px] bg-[var(--color-status-error)]" />
))}
</span>
);
}
/* ── Diff line renderer ────────────────────────────────────── */
function DiffLine({ line }: { line: string }) {
let textClass = 'text-[var(--color-text-secondary)]';
let bgClass = '';
if (line.startsWith('+') && !line.startsWith('+++')) {
textClass = 'text-[var(--color-status-success)]';
bgClass = 'bg-[var(--color-status-success)]/[0.06]';
} else if (line.startsWith('-') && !line.startsWith('---')) {
textClass = 'text-[var(--color-status-error)]';
bgClass = 'bg-[var(--color-status-error)]/[0.06]';
} else if (line.startsWith('@@')) {
textClass = 'text-[var(--color-accent-sky)]';
} else if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
textClass = 'text-[var(--color-text-muted)]';
}
return <div className={`${textClass} ${bgClass} -mx-3 px-3`}>{line || '\u00A0'}</div>;
}
/* ── Single file row ───────────────────────────────────────── */
function ChangedFileRow({
file,
isSelected,
onToggleSelect,
canSelect,
}: {
file: ProjectGitRunChangedFile;
isSelected: boolean;
onToggleSelect: () => void;
canSelect: boolean;
}) {
const [diffExpanded, setDiffExpanded] = useState(false);
const hasPreview = !!file.preview?.diff || !!file.preview?.newFileContents;
const dir = fileDir(file.path);
const base = fileBaseName(file.path);
return (
<div className="border-b border-[var(--color-border-subtle)] last:border-b-0">
<div className="flex items-center gap-1 px-2 py-[5px] text-[10px]">
{/* Select checkbox */}
{canSelect && (
<button
className={`flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors duration-100 ${
isSelected
? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
: 'border-[var(--color-border)] bg-transparent hover:border-[var(--color-text-muted)]'
}`}
onClick={onToggleSelect}
type="button"
aria-label={`${isSelected ? 'Deselect' : 'Select'} ${file.path}`}
aria-pressed={isSelected}
>
{isSelected && <Check className="size-2" />}
</button>
)}
{/* Expand diff button */}
<button
className="flex min-w-0 flex-1 items-center gap-1.5 text-left transition-colors duration-150 hover:bg-[var(--color-surface-3)]/40 disabled:cursor-default"
disabled={!hasPreview}
onClick={hasPreview ? () => setDiffExpanded(!diffExpanded) : undefined}
type="button"
aria-expanded={hasPreview ? diffExpanded : undefined}
>
{hasPreview ? (
<ChevronRight
className={`size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${diffExpanded ? 'rotate-90' : ''}`}
/>
) : (
<span className="w-2.5 shrink-0" />
)}
{kindIcon(file.kind)}
<span className="min-w-0 flex-1 truncate font-mono">
{dir && <span className="text-[var(--color-text-muted)]">{dir}</span>}
<span className="text-[var(--color-text-primary)]">{base}</span>
</span>
<span className="shrink-0 rounded px-1 py-px text-[8px] font-semibold uppercase tracking-wider bg-[var(--color-surface-3)] text-[var(--color-text-muted)]">
{kindLabel(file.kind)}
</span>
{originBadge(file.origin)}
{(file.additions > 0 || file.deletions > 0) && (
<span className="flex items-center gap-1.5 shrink-0 font-mono">
{file.additions > 0 && <span className="text-[var(--color-status-success)]">+{file.additions}</span>}
{file.deletions > 0 && <span className="text-[var(--color-status-error)]">{file.deletions}</span>}
<DiffStatsBar additions={file.additions} deletions={file.deletions} />
</span>
)}
</button>
</div>
{/* Diff preview */}
{diffExpanded && file.preview && (
<div className="border-t border-[var(--color-border-subtle)]">
<pre className="max-h-64 overflow-auto bg-[var(--color-surface-0)] px-3 py-1.5 font-mono text-[10px] leading-relaxed">
{file.preview.diff
? file.preview.diff.split('\n').map((line, i) => <DiffLine key={i} line={line} />)
: file.preview.newFileContents
? file.preview.newFileContents.split('\n').map((line, i) => (
<div key={i} className="text-[var(--color-text-secondary)]">{line || '\u00A0'}</div>
))
: file.preview.isBinary
? <div className="text-[var(--color-text-muted)] italic">Binary file</div>
: null}
</pre>
</div>
)}
</div>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface RunChangeSummaryCardProps {
summary: ProjectGitRunChangeSummary;
sessionId: string;
runId: string;
onDiscard: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}
export function RunChangeSummaryCard({
summary,
sessionId,
runId,
onDiscard,
onOpenCommitComposer,
}: RunChangeSummaryCardProps) {
const [expanded, setExpanded] = useState(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [discarding, setDiscarding] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<'bulk' | 'selected' | undefined>();
const revertableFiles = useMemo(
() => summary.files.filter((file) => file.canRevert),
[summary.files],
);
const hasRevertable = revertableFiles.length > 0;
const toggleSelect = useCallback((path: string) => {
setSelectedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const handleDiscard = useCallback(async (mode: 'bulk' | 'selected') => {
setDiscarding(true);
try {
if (mode === 'selected') {
const files: ProjectGitFileReference[] = summary.files
.filter((f) => selectedPaths.has(f.path))
.map((f) => ({ path: f.path, previousPath: f.previousPath }));
await onDiscard(sessionId, runId, files);
setSelectedPaths(new Set());
} else {
await onDiscard(sessionId, runId);
}
} finally {
setDiscarding(false);
setConfirmDiscard(undefined);
}
}, [onDiscard, sessionId, runId, selectedPaths, summary.files]);
const selectedRevertableCount = useMemo(
() => revertableFiles.filter((f) => selectedPaths.has(f.path)).length,
[revertableFiles, selectedPaths],
);
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]/80">
{/* Header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-[var(--color-surface-2)]/40"
onClick={() => setExpanded(!expanded)}
type="button"
aria-expanded={expanded}
aria-label={`${summary.fileCount} files changed by this run`}
>
<ChevronRight
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
<GitBranch className="size-3 shrink-0 text-[var(--color-accent-sky)]" />
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{summary.fileCount} {summary.fileCount === 1 ? 'file' : 'files'} changed
</span>
{(summary.additions > 0 || summary.deletions > 0) && (
<span className="flex items-center gap-1.5 font-mono text-[10px]">
{summary.additions > 0 && (
<span className="text-[var(--color-status-success)]">+{summary.additions}</span>
)}
{summary.deletions > 0 && (
<span className="text-[var(--color-status-error)]">{summary.deletions}</span>
)}
<DiffStatsBar additions={summary.additions} deletions={summary.deletions} />
</span>
)}
{summary.branchChanged && (
<span className="ml-auto flex items-center gap-1 text-[9px] text-[var(--color-status-warning)]">
<AlertTriangle className="size-2.5" />
branch changed
</span>
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-[var(--color-border-subtle)]">
{/* Branch info */}
{summary.branchChanged && summary.branchAtStart && summary.branchAtEnd && (
<div className="flex items-center gap-1.5 border-b border-[var(--color-border-subtle)] px-3 py-1.5 text-[9px] text-[var(--color-text-muted)]">
<GitBranch className="size-2.5" />
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtStart}</span>
<span></span>
<span className="font-mono text-[var(--color-text-secondary)]">{summary.branchAtEnd}</span>
</div>
)}
{/* File list */}
<div>
{summary.files.map((file) => (
<ChangedFileRow
canSelect={hasRevertable && file.canRevert}
file={file}
isSelected={selectedPaths.has(file.path)}
key={file.path}
onToggleSelect={() => toggleSelect(file.path)}
/>
))}
</div>
{/* Actions */}
{(hasRevertable || onOpenCommitComposer) && (
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)] px-3 py-2">
{/* Commit button */}
{onOpenCommitComposer && (
<button
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1 text-[10px] font-medium text-white transition-colors duration-150 hover:bg-[var(--color-accent-hover)]"
onClick={onOpenCommitComposer}
type="button"
>
<ArrowDownToLine className="size-3" />
Commit changes
</button>
)}
<div className="flex-1" />
{/* Discard actions */}
{hasRevertable && !confirmDiscard && (
<>
{selectedRevertableCount > 0 && (
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-status-error)] transition-colors duration-150 hover:bg-[var(--color-status-error)]/10"
disabled={discarding}
onClick={() => setConfirmDiscard('selected')}
type="button"
>
<Trash2 className="size-3" />
Discard {selectedRevertableCount} selected
</button>
)}
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-status-error)]"
disabled={discarding}
onClick={() => setConfirmDiscard('bulk')}
type="button"
>
<RotateCcw className="size-3" />
Discard all
</button>
</>
)}
{/* Confirmation */}
{confirmDiscard && (
<div className="flex items-center gap-1.5 rounded-md border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/5 px-2.5 py-1" role="alert">
<AlertTriangle className="size-3 text-[var(--color-status-error)]" />
<span className="text-[10px] text-[var(--color-status-error)]">
{confirmDiscard === 'bulk'
? `Revert all ${revertableFiles.length} files to pre-run state?`
: `Revert ${selectedRevertableCount} selected files?`}
</span>
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-status-error)] transition-colors duration-100 hover:bg-[var(--color-status-error)]/15"
disabled={discarding}
onClick={() => void handleDiscard(confirmDiscard)}
type="button"
>
{discarding ? <Loader2 className="size-2.5 animate-spin" /> : <Check className="size-2.5" />}
Yes
</button>
<button
className="rounded px-1.5 py-0.5 text-[9px] font-semibold text-[var(--color-text-muted)] transition-colors duration-100 hover:bg-[var(--color-surface-3)]"
onClick={() => setConfirmDiscard(undefined)}
type="button"
>
Cancel
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react';
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
const COMPLETION_GRACE_MS = 3000;
function formatElapsed(startedAt: string): string {
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
@@ -37,9 +39,10 @@ function ElapsedTimer({ startedAt }: { startedAt: string }) {
interface SubagentActivityCardProps {
subagent: ActiveSubagent;
fading?: boolean;
}
function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
function SubagentActivityCard({ subagent, fading }: SubagentActivityCardProps) {
const borderClass =
subagent.status === 'running'
? 'border-[var(--color-accent-sky)]/20'
@@ -49,7 +52,7 @@ function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
return (
<div
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-200 ${borderClass}`}
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-300 ${borderClass} ${fading ? 'opacity-0' : 'opacity-100'}`}
role="status"
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
>
@@ -73,16 +76,68 @@ interface SubagentActivityListProps {
}
export function SubagentActivityList({ subagents }: SubagentActivityListProps) {
// Track recently-completed subagent IDs so we can show them briefly
const [recentlyDone, setRecentlyDone] = useState<Set<string>>(new Set());
const [fading, setFading] = useState<Set<string>>(new Set());
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
for (const sub of subagents) {
if (sub.status !== 'running' && !recentlyDone.has(sub.toolCallId) && !timersRef.current.has(sub.toolCallId)) {
// Newly completed — track it
setRecentlyDone((prev) => new Set(prev).add(sub.toolCallId));
const fadeTimer = setTimeout(() => {
setFading((prev) => new Set(prev).add(sub.toolCallId));
}, COMPLETION_GRACE_MS - 300);
const removeTimer = setTimeout(() => {
setRecentlyDone((prev) => {
const next = new Set(prev);
next.delete(sub.toolCallId);
return next;
});
setFading((prev) => {
const next = new Set(prev);
next.delete(sub.toolCallId);
return next;
});
timersRef.current.delete(sub.toolCallId);
}, COMPLETION_GRACE_MS);
timersRef.current.set(sub.toolCallId, removeTimer);
// Store fade timer for cleanup
timersRef.current.set(`fade-${sub.toolCallId}`, fadeTimer);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subagents]);
// Cleanup timers on unmount
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, []);
if (subagents.length === 0) return null;
// Only show running subagents in the chat stream
const visible = subagents.filter((s) => s.status === 'running');
// Show running subagents + recently-completed ones within the grace period
const visible = subagents.filter(
(s) => s.status === 'running' || recentlyDone.has(s.toolCallId),
);
if (visible.length === 0) return null;
return (
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
{visible.map((subagent) => (
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
<SubagentActivityCard
key={subagent.toolCallId}
subagent={subagent}
fading={fading.has(subagent.toolCallId)}
/>
))}
</div>
);
@@ -1,120 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
import type { ChatMessageRecord } from '@shared/domain/session';
interface ThinkingProcessProps {
messages: ChatMessageRecord[];
isActive: boolean;
turnStartedAt?: string;
}
export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingProcessProps) {
const [expanded, setExpanded] = useState(false);
const wasActiveRef = useRef(isActive);
// Auto-expand when the turn is active and thinking messages appear.
// Auto-collapse once the turn finishes.
useEffect(() => {
if (isActive && messages.length > 0) {
setExpanded(true);
} else if (wasActiveRef.current && !isActive) {
setExpanded(false);
}
wasActiveRef.current = isActive;
}, [isActive, messages.length]);
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
const elapsed = useMemo(() => {
if (!turnStartedAt || messages.length === 0) return undefined;
const start = new Date(turnStartedAt).getTime();
const lastMessage = messages[messages.length - 1];
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 2) return undefined;
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
}, [turnStartedAt, messages, isActive]);
if (messages.length === 0) {
return null;
}
const stepCount = messages.length;
const summaryParts: string[] = [];
if (elapsed) summaryParts.push(`${elapsed}`);
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
return (
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
<button
type="button"
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Brain className="size-3.5 shrink-0 text-[var(--color-accent-purple)]" />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className="text-[var(--color-text-secondary)]">Thinking</span>
<ThinkingPulse />
</span>
) : (
<span className="text-[var(--color-text-secondary)]">
Thought for {summaryParts.join(' · ')}
</span>
)}
<span className="ml-auto shrink-0">
{expanded
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
</span>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-1.5">
{messages.map((message) => (
<ThinkingStep key={message.id} message={message} />
))}
</div>
</div>
)}
</div>
);
}
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
return (
<div className="flex gap-2 text-[12px] leading-relaxed">
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]"></span>
<div className="min-w-0">
{message.authorName && (
<span className="mr-1.5 font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[var(--color-text-muted)]">{preview}</span>
</div>
</div>
);
}
function ThinkingPulse() {
return (
<span className="inline-flex items-center gap-0.5">
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
</span>
);
}
function truncatePreview(text: string, maxLength: number): string {
const firstLine = text.split('\n')[0] ?? '';
const cleaned = firstLine.trim();
if (cleaned.length <= maxLength) return cleaned;
return `${cleaned.slice(0, maxLength)}`;
}
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import {
formatToolCallSummary,
formatToolArgumentValue,
getDisplayableArguments,
} from '@renderer/lib/toolCallSummary';
export interface ToolCallDetailPanelProps {
toolName?: string;
toolArguments?: Record<string, unknown>;
}
export function ToolCallDetailPanel({ toolName, toolArguments }: ToolCallDetailPanelProps) {
const [expanded, setExpanded] = useState(false);
const summary = formatToolCallSummary(toolName, toolArguments);
const displayArgs = getDisplayableArguments(toolArguments);
const hasExpandableContent = displayArgs.length > 0;
if (!summary && !hasExpandableContent) return null;
return (
<div className="mt-0.5">
{/* Inline summary — always visible when summary exists */}
<button
type="button"
className={`group flex max-w-full items-start gap-1 text-left text-[11px] leading-snug ${
hasExpandableContent
? 'cursor-pointer hover:text-[var(--color-text-secondary)]'
: 'cursor-default'
}`}
onClick={hasExpandableContent ? () => setExpanded((prev) => !prev) : undefined}
aria-expanded={hasExpandableContent ? expanded : undefined}
aria-label={hasExpandableContent ? `Toggle ${toolName} arguments` : undefined}
tabIndex={hasExpandableContent ? 0 : -1}
onKeyDown={hasExpandableContent
? (e) => { if (e.key === ' ') { e.preventDefault(); setExpanded((prev) => !prev); } }
: undefined}
>
{hasExpandableContent && (
<ChevronRight
className={`mt-px size-2.5 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
)}
{summary && (
<span className="min-w-0 truncate font-mono text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]">
{summary}
</span>
)}
</button>
{/* Expanded argument list */}
{expanded && hasExpandableContent && (
<div className="mt-1 overflow-hidden rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/80">
<div className="max-h-48 overflow-auto">
{displayArgs.map(([key, value]) => {
const formatted = formatToolArgumentValue(value);
const isMultiline = formatted.includes('\n') || formatted.length > 120;
return (
<div
key={key}
className="border-b border-[var(--color-border-subtle)] px-2 py-1 last:border-b-0"
>
<span className="text-[10px] font-semibold tracking-wide text-[var(--color-accent-purple)]">
{key}
</span>
{isMultiline ? (
<pre className="mt-0.5 max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-[var(--color-text-secondary)]">
{formatted}
</pre>
) : (
<span className="ml-1.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
{formatted}
</span>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,420 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
ArrowRight,
Brain,
CheckCircle2,
ChevronDown,
ChevronRight,
MessageSquare,
ShieldAlert,
Wrench,
XCircle,
Zap,
} from 'lucide-react';
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
/* ── Types ─────────────────────────────────────────────────── */
/** A unified activity stream item, merging chat thinking messages
* and run timeline events into a single chronological list. */
type ActivityStreamItem =
| { kind: 'thinking-step'; message: ChatMessageRecord }
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
/** Events to skip in the inline panel (redundant or implicit). */
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
/* ── Props ─────────────────────────────────────────────────── */
export interface TurnActivityPanelProps {
thinkingMessages: ChatMessageRecord[];
run?: SessionRunRecord;
isActive: boolean;
turnStartedAt?: string;
sessionId: string;
/** Agent names in this turn group — used to scope run events in multi-agent runs. */
agentNames?: ReadonlySet<string>;
/** True when this panel is the last one sharing a given run (controls git summary / discard). */
isLastRunPanel?: boolean;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}
/* ── Helpers ───────────────────────────────────────────────── */
function truncatePreview(text: string, maxLength: number): string {
const firstLine = text.split('\n')[0] ?? '';
const cleaned = firstLine.trim();
if (cleaned.length <= maxLength) return cleaned;
return `${cleaned.slice(0, maxLength)}`;
}
function buildActivityStream(
thinkingMessages: ChatMessageRecord[],
events: readonly RunTimelineEventRecord[],
): ActivityStreamItem[] {
const items: ActivityStreamItem[] = [];
for (const msg of thinkingMessages) {
items.push({ kind: 'thinking-step', message: msg });
}
for (const event of events) {
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
items.push({ kind: 'timeline-event', event });
}
// Sort chronologically by timestamp
items.sort((a, b) => {
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
return new Date(tsA).getTime() - new Date(tsB).getTime();
});
return items;
}
function formatSummaryParts(summary: ActivitySummary): string[] {
const parts: string[] = [];
if (summary.toolCalls > 0) {
parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`);
}
if (summary.handoffs > 0) {
parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`);
}
if (summary.approvals > 0) {
parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`);
}
if (summary.thinkingSteps > 0) {
parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`);
}
return parts;
}
/* ── Event icon ────────────────────────────────────────────── */
function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
const base = 'size-3 shrink-0';
switch (kind) {
case 'tool-call':
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
case 'approval':
return (
<ShieldAlert
className={`${base} ${
status === 'error'
? 'text-[var(--color-status-error)]'
: status === 'running'
? 'text-[var(--color-status-warning)]'
: 'text-[var(--color-status-success)]'
}`}
/>
);
case 'handoff':
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
case 'message':
return <MessageSquare className={`${base} text-[var(--color-accent-sky)]`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
case 'run-cancelled':
return <XCircle className={`${base} text-[var(--color-text-muted)]`} />;
case 'run-failed':
return <AlertTriangle className={`${base} text-[var(--color-status-error)]`} />;
default:
return <Zap className={`${base} text-[var(--color-text-muted)]`} />;
}
}
/* ── Activity event row ────────────────────────────────────── */
function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) {
const label = formatEventLabel(event);
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<ActivityEventIcon kind={event.kind} status={event.status} />
</div>
<div className="min-w-0 flex-1">
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
{label}
</span>
{/* Approval kind badge */}
{event.kind === 'approval' && event.approvalKind && (
<span
className={`ml-1.5 inline-flex rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
event.status === 'running'
? 'bg-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
: event.status === 'completed'
? 'bg-[var(--color-status-success)]/15 text-[var(--color-status-success)]'
: 'bg-[var(--color-status-error)]/15 text-[var(--color-status-error)]'
}`}
>
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
</span>
)}
{/* Content preview for message events */}
{event.kind === 'message' && event.content && (
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
{truncateContent(event.content, 120)}
</p>
)}
{/* Approval detail */}
{event.kind === 'approval' && event.approvalDetail && (
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
{truncateContent(event.approvalDetail, 120)}
</p>
)}
{/* Error detail */}
{event.error && (
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-status-error)]/80">
{truncateContent(event.error, 120)}
</p>
)}
{/* Tool call argument details */}
{event.kind === 'tool-call' && (
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
)}
{/* File change preview for tool-call events */}
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
<div className="mt-1">
<FileChangePreview fileChanges={event.fileChanges} />
</div>
)}
</div>
</div>
);
}
/* ── Thinking step row ─────────────────────────────────────── */
function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
if (message.pending && !message.content) return null;
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
{message.authorName && (
<span className="mr-1.5 text-[12px] font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[12px] text-[var(--color-text-muted)]">{preview}</span>
</div>
</div>
);
}
/* ── Active pulse dots ─────────────────────────────────────── */
function ActivityPulse() {
return (
<span className="inline-flex items-center gap-0.5">
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
</span>
);
}
/* ── Main component ────────────────────────────────────────── */
export function TurnActivityPanel({
thinkingMessages,
run,
isActive,
turnStartedAt,
sessionId,
agentNames,
isLastRunPanel,
onDiscard,
onOpenCommitComposer,
}: TurnActivityPanelProps) {
const [expanded, setExpanded] = useState(false);
const wasActiveRef = useRef(isActive);
// Auto-expand when the turn is active (run exists or thinking arrives).
// Auto-collapse once the turn finishes.
useEffect(() => {
if (isActive && (thinkingMessages.length > 0 || run)) {
setExpanded(true);
} else if (wasActiveRef.current && !isActive) {
setExpanded(false);
}
wasActiveRef.current = isActive;
}, [isActive, thinkingMessages.length, run]);
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
// When the run is shared across multiple panels (multi-agent sequential),
// scope events to only those belonging to this panel's agents.
const scopedEvents = useMemo(
() => filterEventsByAgent(run?.events ?? [], agentNames),
[run?.events, agentNames],
);
// Derive per-agent timing from the scoped events when agent names are set
// (multi-agent run). For single-agent runs, use the run-level start time.
const effectiveTurnStartedAt = useMemo(() => {
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
return turnStartedAt;
}
// Use the earliest scoped event as the start time for this agent's panel.
let earliest = turnStartedAt;
for (const e of scopedEvents) {
if (!earliest || e.occurredAt < earliest) {
earliest = e.occurredAt;
break; // events are already in insertion order (chronological)
}
}
return earliest;
}, [agentNames, scopedEvents, turnStartedAt]);
const elapsed = useElapsedTimer(
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
isActive,
);
const summary = useMemo(
() => summarizeActivity(thinkingMessages, scopedEvents),
[thinkingMessages, scopedEvents],
);
const activityStream = useMemo(
() => buildActivityStream(thinkingMessages, scopedEvents),
[thinkingMessages, scopedEvents],
);
// Nothing to show — no thinking messages, no run, and not active
if (thinkingMessages.length === 0 && !run) {
return null;
}
const summaryParts = formatSummaryParts(summary);
const runStatus = run?.status;
const isCompleted = runStatus === 'completed';
const isFailed = runStatus === 'error';
const isCancelled = runStatus === 'cancelled';
const isTerminated = isCompleted || isFailed || isCancelled;
// Only show git summary and discard on the last panel for a given run
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
// Build the summary label
let summaryLabel: string;
if (isActive) {
summaryLabel = 'Working';
} else if (isFailed) {
summaryLabel = elapsed ? `Failed after ${elapsed}` : 'Failed';
} else if (isCancelled) {
summaryLabel = elapsed ? `Cancelled after ${elapsed}` : 'Cancelled';
} else if (elapsed) {
summaryLabel = `Completed in ${elapsed}`;
} else {
summaryLabel = 'Completed';
}
const statusColorClass = isFailed
? 'text-[var(--color-status-error)]'
: isCancelled
? 'text-[var(--color-text-muted)]'
: isActive
? 'text-[var(--color-text-secondary)]'
: 'text-[var(--color-text-secondary)]';
return (
<div
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
isActive
? 'border-[var(--color-accent)]/30'
: isFailed
? 'border-[var(--color-status-error)]/20'
: 'border-[var(--color-border)]/50'
}`}
>
{/* Summary header */}
<button
type="button"
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
}`}
>
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className={statusColorClass}>{summaryLabel}</span>
<ActivityPulse />
</span>
) : (
<span className={statusColorClass}>{summaryLabel}</span>
)}
{/* Inline counters */}
{summaryParts.length > 0 && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
{'· '}
{summaryParts.join(' · ')}
</span>
)}
<span className="ml-auto shrink-0">
{expanded
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
</span>
</button>
{/* Expanded activity stream */}
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-0.5">
{activityStream.map((item) => {
if (item.kind === 'thinking-step') {
return <ThinkingStepRow key={item.message.id} message={item.message} />;
}
return <ActivityTimelineEventRow key={item.event.id} event={item.event} />;
})}
</div>
{/* Post-run git changes */}
{showGitSummary && (
<div className="mt-2 border-t border-[var(--color-border)]/30 pt-2">
<RunChangeSummaryCard
onDiscard={onDiscard}
onOpenCommitComposer={onOpenCommitComposer}
runId={run.requestId}
sessionId={sessionId}
summary={run.postRunGitSummary!}
/>
</div>
)}
</div>
)}
</div>
);
}
@@ -1,121 +0,0 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { CircleUser, Shuffle, Layers, Radio, Bot } from 'lucide-react';
import type { GraphNodeData } from '@renderer/lib/patternGraph';
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
'user-input': CircleUser,
'user-output': CircleUser,
agent: Bot, // fallback when no provider is resolved
distributor: Shuffle,
collector: Layers,
orchestrator: Radio,
};
const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; text: string }> = {
'user-input': { bg: 'bg-[var(--color-accent)]/10', border: 'border-[var(--color-accent)]/30', text: 'text-[var(--color-accent-sky)]' },
'user-output': { bg: 'bg-[var(--color-accent)]/10', border: 'border-[var(--color-accent)]/30', text: 'text-[var(--color-accent-sky)]' },
agent: { bg: 'bg-[var(--color-surface-2)]/80', border: 'border-[var(--color-border)]/40', text: 'text-[var(--color-text-primary)]' },
distributor: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
collector: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
orchestrator: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
};
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
const colors = kindColors[data.kind] ?? kindColors.agent;
const isAgent = data.kind === 'agent';
const renderIcon = () => {
if (isAgent && data.provider) {
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
}
const FallbackIcon = kindIcons[data.kind] ?? Bot;
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
};
return (
<div
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md backdrop-blur-sm transition ${
colors.bg
} ${selected ? 'ring-2 ring-[var(--color-accent)]/50' : ''} ${colors.border}`}
>
{renderIcon()}
<div className="min-w-0 flex-1">
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
{data.label}
</div>
{isAgent && data.modelLabel && (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
)}
</div>
{data.readOnly && (
<span className="ml-1 rounded bg-[var(--color-surface-3)]/50 px-1 py-0.5 text-[8px] font-medium text-[var(--color-text-muted)]">
SYS
</span>
)}
</div>
);
}
const handleStyles = {
system: '!size-2 !border-[var(--color-border)] !bg-[var(--color-text-secondary)]',
agent: '!size-2 !border-[var(--color-accent-sky)] !bg-[var(--color-accent)]',
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
};
/* user-input: source only (no incoming handle)
user-output: target only (no outgoing handle) */
export const UserInputNode = memo(function UserInputNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.system} />
</>
);
});
export const UserOutputNode = memo(function UserOutputNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.system} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
</>
);
});
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.system} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.system} />
</>
);
});
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
const nodeData = data as unknown as GraphNodeData;
return (
<>
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
<GraphNodeContent data={nodeData} selected={selected ?? false} />
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
</>
);
});
export const graphNodeTypes = {
userInputNode: UserInputNode,
userOutputNode: UserOutputNode,
systemNode: SystemNode,
agentNode: AgentNode,
};
@@ -1,221 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Panel,
MarkerType,
useNodesState,
useEdgesState,
useReactFlow,
type Node,
type Edge,
type OnConnect,
type OnEdgesChange,
type OnNodesChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { LayoutGrid } from 'lucide-react';
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern';
import type { ModelDefinition } from '@shared/domain/models';
import {
addEdge,
autoLayoutGraph,
fromCanvasPositions,
isConnectionAllowed,
isEdgeDeletionAllowed,
removeEdge,
toCanvasEdges,
toCanvasNodes,
type GraphNodeData,
} from '@renderer/lib/patternGraph';
import { graphNodeTypes } from './GraphNodes';
interface PatternGraphCanvasProps {
pattern: PatternDefinition;
availableModels?: ReadonlyArray<ModelDefinition>;
onGraphChange: (graph: PatternGraph) => void;
onAgentRemove: (agentId: string) => void;
onNodeSelect: (nodeId: string | null) => void;
selectedNodeId: string | null;
}
function PatternGraphCanvasInner({
pattern,
availableModels,
onGraphChange,
onAgentRemove,
onNodeSelect,
selectedNodeId,
}: PatternGraphCanvasProps) {
const { fitView } = useReactFlow();
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
const draggingRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState(
toCanvasNodes(graph, pattern.agents, availableModels),
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
toCanvasEdges(graph, pattern.mode),
);
// Sync canvas when pattern changes externally
useEffect(() => {
setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
setEdges(toCanvasEdges(graph, pattern.mode));
}, [graph, pattern.agents, pattern.mode, availableModels, setNodes, setEdges]);
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
(changes) => {
// Intercept node removals and route agent deletions through the
// authoritative removal path (which also removes from pattern.agents).
const removals = changes.filter((c) => c.type === 'remove');
const nonRemovals = changes.filter((c) => c.type !== 'remove');
for (const removal of removals) {
if (removal.type === 'remove') {
const graphNode = graph.nodes.find((n) => n.id === removal.id);
if (graphNode?.kind === 'agent' && graphNode.agentId) {
onAgentRemove(graphNode.agentId);
}
}
}
if (nonRemovals.length > 0) {
onNodesChange(nonRemovals);
}
const hasDragStart = nonRemovals.some(
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
);
const hasDragStop = nonRemovals.some(
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
);
if (hasDragStart) {
draggingRef.current = true;
}
if (hasDragStop && draggingRef.current) {
draggingRef.current = false;
setNodes((currentNodes) => {
const updatedGraph = fromCanvasPositions(pattern, currentNodes);
onGraphChange(updatedGraph);
return currentNodes;
});
}
},
[onNodesChange, pattern, graph, onGraphChange, onAgentRemove, setNodes],
);
const handleEdgesChange: OnEdgesChange = useCallback(
(changes) => {
// Route edge removals through the authoritative graph.
// Non-deletable edges are already protected by the per-edge `deletable`
// flag, so React Flow will not emit removal changes for them.
const removals = changes.filter((c) => c.type === 'remove');
if (removals.length > 0 && isEdgeDeletionAllowed(pattern.mode)) {
let updatedGraph = graph;
for (const removal of removals) {
if (removal.type === 'remove') {
updatedGraph = removeEdge(updatedGraph, removal.id);
}
}
onGraphChange(updatedGraph);
}
const nonRemovals = changes.filter((c) => c.type !== 'remove');
if (nonRemovals.length > 0) {
onEdgesChange(nonRemovals);
}
},
[onEdgesChange, pattern.mode, graph, onGraphChange],
);
const handleConnect: OnConnect = useCallback(
(connection) => {
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
return;
}
if (connection.source && connection.target) {
const updatedGraph = addEdge(graph, connection.source, connection.target);
onGraphChange(updatedGraph);
}
},
[graph, pattern.mode, onGraphChange],
);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<GraphNodeData>) => {
onNodeSelect(node.id);
},
[onNodeSelect],
);
const handlePaneClick = useCallback(() => {
onNodeSelect(null);
}, [onNodeSelect]);
const handleAutoLayout = useCallback(() => {
const layouted = autoLayoutGraph(graph);
onGraphChange(layouted);
// Allow React to render the new positions before fitting
requestAnimationFrame(() => fitView({ padding: 0.3 }));
}, [graph, onGraphChange, fitView]);
return (
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
<ReactFlow
nodes={nodes.map((n) => ({
...n,
selected: n.id === selectedNodeId,
}))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
nodeTypes={graphNodeTypes}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'default',
style: { stroke: '#245CF9', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#245CF9' },
}}
connectionLineStyle={{ stroke: '#245CF9', strokeWidth: 1.5 }}
deleteKeyCode="Delete"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
<Panel position="top-right">
<button
type="button"
onClick={handleAutoLayout}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
title="Auto-layout nodes"
>
<LayoutGrid className="size-3.5" />
Auto layout
</button>
</Panel>
</ReactFlow>
</div>
);
}
export function PatternGraphCanvas(props: PatternGraphCanvasProps) {
return (
<ReactFlowProvider>
<PatternGraphCanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -1,277 +0,0 @@
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Radio, Shuffle, Trash2 } from 'lucide-react';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import type {
OrchestrationMode,
PatternAgentDefinition,
PatternGraph,
PatternGraphNodeKind,
} from '@shared/domain/pattern';
import {
canMoveSequential,
findAgentForNode,
swapSequentialOrder,
} from '@renderer/lib/patternGraph';
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
interface PatternGraphInspectorProps {
availableModels: ReadonlyArray<ModelDefinition>;
agents: PatternAgentDefinition[];
graph: PatternGraph;
mode: OrchestrationMode;
selectedNodeId: string | null;
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
onAgentRemove: (agentId: string) => void;
onGraphChange: (graph: PatternGraph) => void;
}
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const base =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${base} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={base}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
const kindIcons: Record<PatternGraphNodeKind, typeof Bot> = {
'user-input': CircleUser,
'user-output': CircleUser,
agent: Bot,
distributor: Shuffle,
collector: Layers,
orchestrator: Radio,
};
const kindLabels: Partial<Record<PatternGraphNodeKind, string>> = {
'user-input': 'User Input',
'user-output': 'User Output',
distributor: 'Distributor',
collector: 'Collector',
orchestrator: 'Orchestrator',
};
const kindDescriptions: Partial<Record<PatternGraphNodeKind, string>> = {
'user-input': 'Entry point — receives user messages.',
'user-output': 'Exit point — returns final response to the user.',
distributor: 'Fans user input to all agents in parallel.',
collector: 'Aggregates parallel agent responses.',
orchestrator: 'Manages group chat round-robin turns.',
};
function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
const Icon = kindIcons[kind];
return (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-accent)]/10">
<Icon className="size-4 text-[var(--color-accent-sky)]" />
</div>
<div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">{kindLabels[kind]}</div>
<span className="rounded bg-[var(--color-surface-3)]/50 px-1.5 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]">
System node
</span>
</div>
</div>
<p className="text-[12px] leading-relaxed text-[var(--color-text-muted)]">{kindDescriptions[kind]}</p>
</div>
);
}
function AgentNodeInspector({
agent,
availableModels,
mode,
graph,
nodeId,
onAgentChange,
onAgentRemove,
onGraphChange,
}: {
agent: PatternAgentDefinition;
availableModels: ReadonlyArray<ModelDefinition>;
mode: OrchestrationMode;
graph: PatternGraph;
nodeId: string;
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
onAgentRemove: (agentId: string) => void;
onGraphChange: (graph: PatternGraph) => void;
}) {
const model = findModel(agent.model, availableModels);
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
<Bot className="size-4 text-[var(--color-text-secondary)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">{agent.name || 'Unnamed'}</div>
</div>
<div className="flex items-center gap-1">
{showReorder && (
<>
<button
className="flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-muted)]"
disabled={!canUp}
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'up'))}
title="Move earlier in sequence"
type="button"
>
<ChevronUp className="size-3.5" />
</button>
<button
className="flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-muted)]"
disabled={!canDown}
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'down'))}
title="Move later in sequence"
type="button"
>
<ChevronDown className="size-3.5" />
</button>
</>
)}
<button
className="flex items-center gap-1 text-[12px] text-[var(--color-text-muted)] transition hover:text-red-400"
onClick={() => onAgentRemove(agent.id)}
type="button"
>
<Trash2 className="size-3" />
</button>
</div>
</div>
<InputField
label="Name"
onChange={(v) => onAgentChange(agent.id, { name: v })}
value={agent.name}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
onAgentChange(agent.id, {
model: value,
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
});
}}
value={agent.model}
/>
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => onAgentChange(agent.id, { reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={resolveReasoningEffort(model, agent.reasoningEffort)}
/>
</div>
<InputField
label="Description"
onChange={(v) => onAgentChange(agent.id, { description: v })}
placeholder="What this agent does..."
value={agent.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => onAgentChange(agent.id, { instructions: v })}
placeholder="System prompt for this agent..."
value={agent.instructions}
/>
</div>
);
}
export function PatternGraphInspector({
availableModels,
agents,
graph,
mode,
selectedNodeId,
onAgentChange,
onAgentRemove,
onGraphChange,
}: PatternGraphInspectorProps) {
if (!selectedNodeId) {
return (
<div className="flex h-full items-center justify-center p-4">
<p className="text-center text-[12px] text-[var(--color-text-muted)]">
Select a node on the graph to inspect it
</p>
</div>
);
}
const node = graph.nodes.find((n) => n.id === selectedNodeId);
if (!node) {
return null;
}
if (node.kind !== 'agent') {
return (
<div className="p-4">
<SystemNodeInspector kind={node.kind} />
</div>
);
}
const agent = findAgentForNode(selectedNodeId, graph, agents);
if (!agent) {
return null;
}
return (
<div className="p-4">
<AgentNodeInspector
agent={agent}
availableModels={availableModels}
mode={mode}
graph={graph}
nodeId={selectedNodeId}
onAgentChange={onAgentChange}
onAgentRemove={onAgentRemove}
onGraphChange={onGraphChange}
/>
</div>
);
}
@@ -0,0 +1,209 @@
import { useState, useCallback } from 'react';
import { Check, Copy, ChevronRight, KeyRound, Sparkles } from 'lucide-react';
import { detectedPlatform, type DetectedPlatform } from '@renderer/lib/platform';
import {
installInstructions,
authCommand,
type PlatformInstallInfo,
type InstallMethod,
} from '@renderer/lib/cliInstallInstructions';
interface CliInstallGuideProps {
onRefresh: () => void;
isRefreshing: boolean;
}
function PlatformTab({
info,
active,
onClick,
}: {
info: PlatformInstallInfo;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`relative rounded-md px-3 py-1.5 text-[11px] font-semibold tracking-wide transition-all duration-200 ${
active
? 'bg-[var(--color-surface-3)] text-[var(--color-text-primary)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
aria-pressed={active}
>
{info.displayName}
</button>
);
}
function CommandBlock({
method,
index,
}: {
method: InstallMethod;
index: number;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(method.command);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}, [method.command]);
return (
<div
className="group/cmd space-y-1.5"
style={{ animationDelay: `${index * 60}ms` }}
>
{/* Method label row */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{method.label}
</span>
{method.recommended && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent)]/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-[var(--color-accent)]">
<Sparkles className="size-2.5" />
Recommended
</span>
)}
</div>
{/* Command block */}
<div className="relative flex items-center overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2">
<ChevronRight className="size-3 shrink-0 text-[var(--color-accent)]/60" />
<code className="min-w-0 select-all truncate font-mono text-[12px] leading-relaxed text-[var(--color-text-primary)]">
{method.command}
</code>
</div>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border-subtle)] px-2.5 py-2 text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
title="Copy command"
aria-label={`Copy command: ${method.command}`}
>
{copied ? (
<Check className="size-3 text-[var(--color-status-success)]" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
</div>
);
}
export function CliInstallGuide({ onRefresh, isRefreshing }: CliInstallGuideProps) {
const [activePlatform, setActivePlatform] = useState<DetectedPlatform>(detectedPlatform);
const activeInfo = installInstructions.find((i) => i.platform === activePlatform)!;
return (
<div className="space-y-4">
{/* Step 1: Install */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
1
</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
Install the Copilot CLI
</span>
</div>
{/* Platform tabs */}
<div className="flex items-center gap-1 rounded-lg bg-[var(--color-surface-2)] p-1">
{installInstructions.map((info) => (
<PlatformTab
key={info.platform}
active={activePlatform === info.platform}
info={info}
onClick={() => setActivePlatform(info.platform)}
/>
))}
</div>
{/* Commands for active platform */}
<div className="space-y-3">
{activeInfo.methods.map((method, i) => (
<CommandBlock key={method.label} index={i} method={method} />
))}
</div>
</div>
{/* Step 2: Authenticate */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
2
</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
Sign in to GitHub
</span>
</div>
<AuthCommandBlock />
</div>
{/* Step 3: Refresh */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-[var(--color-accent)]/15 text-[10px] font-bold text-[var(--color-accent)]">
3
</span>
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">
Refresh connection
</span>
</div>
<button
type="button"
onClick={onRefresh}
disabled={isRefreshing}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/15 disabled:opacity-50"
>
{isRefreshing ? 'Checking…' : 'Check connection'}
</button>
</div>
</div>
);
}
function AuthCommandBlock() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(authCommand);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}, []);
return (
<div className="relative flex items-center overflow-hidden rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2">
<KeyRound className="size-3 shrink-0 text-[var(--color-accent)]/60" />
<code className="min-w-0 select-all truncate font-mono text-[12px] leading-relaxed text-[var(--color-text-primary)]">
{authCommand}
</code>
</div>
<button
type="button"
onClick={handleCopy}
className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border-subtle)] px-2.5 py-2 text-[var(--color-text-muted)] transition-colors duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]"
title="Copy command"
aria-label="Copy authentication command"
>
{copied ? (
<Check className="size-3 text-[var(--color-status-success)]" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
);
}
@@ -0,0 +1,138 @@
import { FormField, TextInput, TextareaInput } from '@renderer/components/ui';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { findModel, type ModelDefinition } from '@shared/domain/models';
import { resolveReasoningEffort } from '@shared/domain/models';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent';
import { ToolingEditorShell } from './ToolingEditorShell';
import { Link2, Workflow } from 'lucide-react';
function validateWorkspaceAgent(agent: WorkspaceAgentDefinition): string | undefined {
if (!agent.name.trim()) return 'Agent name is required.';
if (!agent.model.trim()) return 'Model is required.';
return undefined;
}
export function WorkspaceAgentEditor({
agent,
onChange,
onBack,
onSave,
onDelete,
availableModels,
workflows,
}: {
agent: WorkspaceAgentDefinition;
onChange: (agent: WorkspaceAgentDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
availableModels: ReadonlyArray<ModelDefinition>;
workflows: WorkflowDefinition[];
}) {
const validationError = validateWorkspaceAgent(agent);
const usages = findWorkspaceAgentUsages(agent.id, workflows);
return (
<ToolingEditorShell
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
subtitle="Reusable agent definition"
title={agent.name || 'Untitled Agent'}
>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
General
</h4>
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange({ ...agent, name: value })}
placeholder="e.g. Code Reviewer, Architect, QA Agent"
value={agent.name}
/>
</FormField>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
AI Configuration
</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<ModelSelect
models={availableModels}
onChange={(model) => {
const m = findModel(model, availableModels);
onChange({
...agent,
model,
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
});
}}
value={agent.model}
/>
</div>
<div>
<ReasoningEffortSelect
onChange={(value) => onChange({ ...agent, reasoningEffort: value })}
supportedEfforts={findModel(agent.model, availableModels)?.supportedReasoningEfforts}
value={agent.reasoningEffort}
/>
</div>
</div>
</section>
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Agent Identity
</h4>
<FormField label="Description">
<TextareaInput
onChange={(value) => onChange({ ...agent, description: value })}
placeholder="A short description of this agent's role and purpose"
rows={2}
value={agent.description}
/>
</FormField>
<FormField label="Instructions">
<TextareaInput
onChange={(value) => onChange({ ...agent, instructions: value })}
placeholder="System instructions that define this agent's behavior"
rows={8}
value={agent.instructions}
/>
</FormField>
</section>
{usages.length > 0 && (
<section className="space-y-3">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Used By
</h4>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-glass)]">
{usages.map((usage) => (
<div
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
key={usage.workflowId}
>
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[13px] text-[var(--color-text-secondary)]">
{usage.workflowName}
</span>
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
</div>
))}
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
Referenced by {usages.length} workflow{usages.length === 1 ? '' : 's'}.
Changes to this agent will affect all linked workflows.
</p>
</section>
)}
</ToolingEditorShell>
);
}
+5
View File
@@ -3,10 +3,12 @@ import type { ReactNode } from 'react';
export function FormField({
label,
required,
description,
children,
}: {
label: string;
required?: boolean;
description?: string;
children: ReactNode;
}) {
return (
@@ -16,6 +18,9 @@ export function FormField({
{required && <span className="ml-1 text-[var(--color-status-warning)]">*</span>}
</span>
{children}
{description && (
<p className="mt-1.5 text-[11px] leading-relaxed text-[var(--color-text-muted)]">{description}</p>
)}
</label>
);
}
@@ -0,0 +1,288 @@
import { useCallback } from 'react';
import { Info, Plus, Trash2 } from 'lucide-react';
import type { EdgeCondition, WorkflowConditionRule } from '@shared/domain/workflow';
type ConditionType = 'none' | 'always' | 'property' | 'message-type' | 'expression';
interface ConditionEditorProps {
condition: EdgeCondition | undefined;
onChange: (condition: EdgeCondition | undefined) => void;
disabled?: boolean;
}
const OPERATOR_OPTIONS: { value: WorkflowConditionRule['operator']; label: string }[] = [
{ value: 'equals', label: '=' },
{ value: 'not-equals', label: '≠' },
{ value: 'contains', label: 'contains' },
{ value: 'gt', label: '>' },
{ value: 'lt', label: '<' },
{ value: 'regex', label: 'matches regex' },
];
const inputClasses =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50';
const selectClasses =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-1.5 text-[12px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50 disabled:cursor-not-allowed disabled:opacity-50';
function resolveConditionType(condition: EdgeCondition | undefined): ConditionType {
if (!condition) return 'none';
return condition.type;
}
function emptyRule(): WorkflowConditionRule {
return { propertyPath: '', operator: 'equals', value: '' };
}
function defaultConditionForType(type: ConditionType): EdgeCondition | undefined {
switch (type) {
case 'none':
return undefined;
case 'always':
return { type: 'always' };
case 'property':
return { type: 'property', combinator: 'and', rules: [emptyRule()] };
case 'message-type':
return { type: 'message-type', typeName: '' };
case 'expression':
return { type: 'expression', expression: '' };
}
}
/* ── Property rules editor ─────────────────────────────────── */
function PropertyRuleRow({
rule,
index,
disabled,
onRuleChange,
onRemove,
canRemove,
}: {
rule: WorkflowConditionRule;
index: number;
disabled?: boolean;
onRuleChange: (index: number, patch: Partial<WorkflowConditionRule>) => void;
onRemove: (index: number) => void;
canRemove: boolean;
}) {
return (
<div className="flex items-center gap-1.5">
<input
className={inputClasses}
disabled={disabled}
onChange={(e) => onRuleChange(index, { propertyPath: e.target.value })}
placeholder="property.path"
style={{ flex: 2 }}
value={rule.propertyPath}
/>
<select
className={selectClasses}
disabled={disabled}
onChange={(e) =>
onRuleChange(index, { operator: e.target.value as WorkflowConditionRule['operator'] })
}
style={{ flex: 1.2 }}
value={rule.operator}
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
<input
className={inputClasses}
disabled={disabled}
onChange={(e) => onRuleChange(index, { value: e.target.value })}
placeholder="value"
style={{ flex: 2 }}
value={rule.value}
/>
{canRemove && (
<button
className="flex size-6 shrink-0 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)] disabled:pointer-events-none disabled:opacity-50"
disabled={disabled}
onClick={() => onRemove(index)}
title="Remove rule"
type="button"
>
<Trash2 className="size-3" />
</button>
)}
</div>
);
}
function PropertyRulesEditor({
condition,
disabled,
onChange,
}: {
condition: Extract<EdgeCondition, { type: 'property' }>;
disabled?: boolean;
onChange: (condition: EdgeCondition) => void;
}) {
const rules = condition.rules;
const handleRuleChange = useCallback(
(index: number, patch: Partial<WorkflowConditionRule>) => {
const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r));
onChange({ ...condition, rules: updated });
},
[rules, condition, onChange],
);
const handleRemoveRule = useCallback(
(index: number) => {
onChange({ ...condition, rules: rules.filter((_, i) => i !== index) });
},
[rules, condition, onChange],
);
const handleAddRule = useCallback(() => {
onChange({ ...condition, rules: [...rules, emptyRule()] });
}, [rules, condition, onChange]);
const handleCombinatorChange = useCallback(
(combinator: 'and' | 'or') => {
onChange({ ...condition, combinator });
},
[condition, onChange],
);
return (
<div className="space-y-2">
{rules.length >= 2 && (
<div className="flex items-center gap-2">
<span className="text-[11px] text-[var(--color-text-muted)]">Combine</span>
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)]">
<button
className={`px-2.5 py-1 text-[11px] font-medium transition ${
(condition.combinator ?? 'and') === 'and'
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
disabled={disabled}
onClick={() => handleCombinatorChange('and')}
type="button"
>
AND
</button>
<button
className={`px-2.5 py-1 text-[11px] font-medium transition ${
condition.combinator === 'or'
? 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
disabled={disabled}
onClick={() => handleCombinatorChange('or')}
type="button"
>
OR
</button>
</div>
</div>
)}
{rules.map((rule, i) => (
<PropertyRuleRow
canRemove={rules.length > 1}
disabled={disabled}
index={i}
key={i}
onRemove={handleRemoveRule}
onRuleChange={handleRuleChange}
rule={rule}
/>
))}
<button
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10 disabled:pointer-events-none disabled:opacity-50"
disabled={disabled}
onClick={handleAddRule}
type="button"
>
<Plus className="size-3" />
Add Rule
</button>
</div>
);
}
/* ── Main condition editor ─────────────────────────────────── */
export function ConditionEditor({ condition, onChange, disabled }: ConditionEditorProps) {
const currentType = resolveConditionType(condition);
const handleTypeChange = useCallback(
(type: ConditionType) => {
onChange(defaultConditionForType(type));
},
[onChange],
);
return (
<div className="space-y-2.5">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Condition
</span>
<select
className={selectClasses}
disabled={disabled}
onChange={(e) => handleTypeChange(e.target.value as ConditionType)}
value={currentType}
>
<option value="none">None</option>
<option value="always">Always</option>
<option value="property">Property Rule</option>
<option value="message-type">Message Type</option>
<option value="expression">Expression</option>
</select>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Determines when this edge fires. Use property rules or expressions to create conditional branches.
</p>
</label>
{condition?.type === 'property' && (
<PropertyRulesEditor condition={condition} disabled={disabled} onChange={onChange} />
)}
{condition?.type === 'message-type' && (
<label className="block space-y-1.5">
<span className="text-[11px] text-[var(--color-text-muted)]">Type Name</span>
<input
className={inputClasses}
disabled={disabled}
onChange={(e) => onChange({ ...condition, typeName: e.target.value })}
placeholder="e.g. ApprovalResponse"
value={condition.typeName}
/>
</label>
)}
{condition?.type === 'expression' && (
<div className="space-y-1.5">
<label className="block space-y-1.5">
<span className="text-[11px] text-[var(--color-text-muted)]">Expression</span>
<input
className={`${inputClasses} font-mono`}
disabled={disabled}
onChange={(e) => onChange({ ...condition, expression: e.target.value })}
placeholder='e.g. result.status == "done"'
value={condition.expression}
/>
</label>
<div className="flex items-start gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2.5 py-2">
<Info className="mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Supported: ==, !=, &gt;, &lt;, contains, matches. Combine with &amp;&amp; or ||
</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,233 @@
import { useCallback, useState } from 'react';
import { AlertCircle, FunctionSquare, Plus, Trash2, X } from 'lucide-react';
import type {
InvokeFunctionConfig,
WorkflowNode,
WorkflowNodeConfig,
WorkflowValidationIssue,
} from '@shared/domain/workflow';
interface InvokeFunctionInspectorProps {
node: WorkflowNode;
validationIssues?: WorkflowValidationIssue[];
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
onNodeRemove: (nodeId: string) => void;
}
function InputField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const base =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
<input
className={base}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
</label>
);
}
function stringifyValue(v: unknown): string {
if (typeof v === 'string') return v;
return JSON.stringify(v) ?? '';
}
export function InvokeFunctionInspector({
node,
validationIssues,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
}: InvokeFunctionInspectorProps) {
const config = node.config as InvokeFunctionConfig;
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
const args = config.arguments ?? {};
const argEntries = Object.entries(args);
const [newKey, setNewKey] = useState('');
const patchConfig = useCallback(
(patch: Partial<InvokeFunctionConfig>) => {
onNodeConfigChange(node.id, { ...config, ...patch });
},
[node.id, config, onNodeConfigChange],
);
const handleArgChange = useCallback(
(oldKey: string, newArgKey: string, value: string) => {
const next = { ...args };
if (newArgKey !== oldKey) {
delete next[oldKey];
}
next[newArgKey] = value;
patchConfig({ arguments: next });
},
[args, patchConfig],
);
const handleArgRemove = useCallback(
(key: string) => {
const next = { ...args };
delete next[key];
patchConfig({ arguments: next });
},
[args, patchConfig],
);
const handleArgAdd = useCallback(() => {
const key = newKey.trim() || `arg${argEntries.length + 1}`;
patchConfig({ arguments: { ...args, [key]: '' } });
setNewKey('');
}, [newKey, argEntries.length, args, patchConfig]);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-violet-500/10">
<FunctionSquare className="size-4 text-violet-400" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{node.label || 'Function Tool'}
</div>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => onNodeRemove(node.id)}
title="Remove node"
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
{/* Label */}
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
{/* Function name */}
<InputField
label="Function Name"
onChange={(v) => patchConfig({ functionName: v })}
placeholder="e.g. GetUserData"
value={config.functionName}
/>
{/* Result variable */}
<div className="space-y-1.5">
<InputField
label="Result Variable"
onChange={(v) => patchConfig({ resultVariable: v || undefined })}
placeholder="e.g. Local.result"
value={config.resultVariable ?? ''}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
State path where the return value is stored for use in subsequent steps.
</p>
</div>
{/* Require approval */}
<div className="space-y-1">
<label className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Require Approval
</span>
<input
checked={config.requireApproval === true}
className="size-4 accent-[var(--color-accent)]"
onChange={(e) => patchConfig({ requireApproval: e.target.checked || undefined })}
type="checkbox"
/>
</label>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Pause and require human confirmation before invoking this function.
</p>
</div>
{/* Arguments editor */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Arguments
</span>
<button
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
onClick={handleArgAdd}
title="Add argument"
type="button"
>
<Plus className="size-3.5" />
</button>
</div>
{argEntries.length === 0 && (
<p className="text-[11px] text-[var(--color-text-muted)]">No arguments defined.</p>
)}
{argEntries.map(([key, value]) => (
<div className="flex items-center gap-1.5" key={key}>
<input
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => handleArgChange(key, e.target.value, stringifyValue(value))}
placeholder="key"
value={key}
/>
<input
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => handleArgChange(key, key, e.target.value)}
placeholder="value"
value={stringifyValue(value)}
/>
<button
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => handleArgRemove(key)}
title="Remove argument"
type="button"
>
<X className="size-3" />
</button>
</div>
))}
</div>
{/* Validation issues */}
{nodeIssues.length > 0 && (
<div className="space-y-1">
{nodeIssues.map((issue, i) => (
<div
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
issue.level === 'error'
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
}`}
key={`${issue.field ?? 'v'}-${i}`}
>
<AlertCircle className="mt-0.5 size-3 shrink-0" />
<span>{issue.message}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,513 @@
import { useCallback, useMemo, useState } from 'react';
import {
ArrowRightLeft,
Bot,
ChevronDown,
Layers,
MessageCircle,
Repeat,
Route,
User,
} from 'lucide-react';
import type {
HandoffModeSettings,
GroupChatModeSettings,
WorkflowDefinition,
WorkflowNode,
WorkflowOrchestrationMode,
} from '@shared/domain/workflow';
import {
createDefaultModeSettings,
inferWorkflowOrchestrationMode,
isBuilderBasedMode,
isGraphBasedMode,
scaffoldGraphForMode,
syncBuilderModeEdgeIterations,
} from '@shared/domain/workflow';
import { FormField, InfoCallout, SelectInput, ToggleSwitch } from '@renderer/components/ui';
/* ── Mode metadata ─────────────────────────────────────────── */
interface ModeOption {
value: WorkflowOrchestrationMode;
label: string;
description: string;
icon: typeof Bot;
accentClass: string;
}
const modeOptions: ModeOption[] = [
{
value: 'single',
label: 'Single Agent',
description: 'One agent handles the full conversation directly.',
icon: User,
accentClass: 'text-emerald-400',
},
{
value: 'sequential',
label: 'Sequential',
description: 'Agents execute in order, each seeing the full conversation history.',
icon: ArrowRightLeft,
accentClass: 'text-sky-400',
},
{
value: 'concurrent',
label: 'Concurrent',
description: 'Agents execute in parallel via fan-out, results collected at barrier.',
icon: Layers,
accentClass: 'text-amber-400',
},
{
value: 'handoff',
label: 'Handoff',
description: 'Triage agent routes requests to specialists via handoff tool calls.',
icon: Route,
accentClass: 'text-violet-400',
},
{
value: 'group-chat',
label: 'Group Chat',
description: 'Agents take turns in a managed conversation loop.',
icon: MessageCircle,
accentClass: 'text-rose-400',
},
];
const modeMap = new Map(modeOptions.map((m) => [m.value, m]));
/* ── Scaffold confirmation dialog ──────────────────────────── */
function ScaffoldDialog({
fromMode,
toMode,
onAccept,
onDecline,
}: {
fromMode: string;
toMode: WorkflowOrchestrationMode;
onAccept: () => void;
onDecline: () => void;
}) {
const target = modeMap.get(toMode);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="scaffold-dialog-title"
>
<div
className="mx-4 w-full max-w-md animate-[palette-enter_0.2s_ease-out] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-2)] p-6 shadow-2xl"
>
<h3
id="scaffold-dialog-title"
className="mb-2 text-[14px] font-semibold text-[var(--color-text-primary)]"
>
Restructure graph for {target?.label ?? toMode}?
</h3>
<p className="mb-5 text-[13px] leading-relaxed text-[var(--color-text-secondary)]">
This will update edges to match the <span className="font-medium text-[var(--color-text-primary)]">{target?.label ?? toMode}</span> topology while keeping your existing agents.
{fromMode && (
<span className="mt-1 block text-[12px] text-[var(--color-text-muted)]">
Changing from {fromMode}.
</span>
)}
</p>
<div className="flex items-center justify-end gap-2.5">
<button
type="button"
onClick={onDecline}
className="rounded-lg px-4 py-2 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
>
Keep current graph
</button>
<button
type="button"
onClick={onAccept}
className="rounded-lg bg-[var(--color-accent)] px-4 py-2 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-hover)]"
>
Restructure
</button>
</div>
</div>
</div>
);
}
/* ── Mode selector card ────────────────────────────────────── */
function ModeCard({
option,
selected,
onClick,
}: {
option: ModeOption;
selected: boolean;
onClick: () => void;
}) {
const Icon = option.icon;
return (
<button
type="button"
onClick={onClick}
className={`group flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-all duration-200 ${
selected
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)] shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_16px_rgba(36,92,249,0.06)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-1)] hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-2)]'
}`}
aria-pressed={selected}
>
<div className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg ${
selected ? 'bg-[var(--color-accent)]/15' : 'bg-[var(--color-surface-3)]'
}`}>
<Icon className={`size-3.5 ${selected ? 'text-[var(--color-accent)]' : option.accentClass}`} />
</div>
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-medium ${
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]'
}`}>
{option.label}
</div>
<div className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
{option.description}
</div>
</div>
</button>
);
}
/* ── Handoff settings sub-panel ────────────────────────────── */
function HandoffSettingsPanel({
settings,
agentNodes,
onChange,
}: {
settings: HandoffModeSettings;
agentNodes: WorkflowNode[];
onChange: (settings: HandoffModeSettings) => void;
}) {
const triageOptions = useMemo(() => [
{ value: '', label: 'First agent (default)' },
...agentNodes.map((node) => ({
value: node.id,
label: node.label || (node.config.kind === 'agent' ? node.config.name : node.id),
})),
], [agentNodes]);
const filteringOptions = [
{ value: 'none', label: 'None — All tools available' },
{ value: 'handoff-only', label: 'Handoff-only — Restrict to handoff tools' },
{ value: 'all', label: 'All — Full tool filtering' },
];
return (
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
<div className="flex items-center gap-2">
<Route className="size-3.5 text-violet-400" />
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Handoff Settings
</span>
</div>
<FormField label="Triage Agent" description="The entry-point agent that receives requests and decides which specialist to hand off to.">
<SelectInput
value={settings.triageAgentNodeId ?? ''}
options={triageOptions}
onChange={(v) => onChange({ ...settings, triageAgentNodeId: v || undefined })}
/>
</FormField>
<FormField label="Tool-Call Filtering" description="Controls which tools each agent can see and invoke during handoff routing.">
<SelectInput
value={settings.toolCallFiltering}
options={filteringOptions}
onChange={(v) => onChange({ ...settings, toolCallFiltering: v as HandoffModeSettings['toolCallFiltering'] })}
/>
</FormField>
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
<div>
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Return to Previous</div>
<p className="text-[11px] text-[var(--color-text-muted)]">Allow specialists to hand back to the previous agent</p>
</div>
<button type="button" className="cursor-pointer" onClick={() => onChange({ ...settings, returnToPrevious: !settings.returnToPrevious })}>
<ToggleSwitch enabled={settings.returnToPrevious} />
</button>
</div>
<FormField label="Custom Handoff Instructions" description="Additional guidance given to agents when performing handoffs between specialists.">
<textarea
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
rows={3}
placeholder="Override default handoff guidance (optional)"
value={settings.handoffInstructions ?? ''}
onChange={(e) => onChange({ ...settings, handoffInstructions: e.target.value || undefined })}
/>
</FormField>
</div>
);
}
/* ── Group-chat settings sub-panel ─────────────────────────── */
function GroupChatSettingsPanel({
settings,
onChange,
}: {
settings: GroupChatModeSettings;
onChange: (settings: GroupChatModeSettings) => void;
}) {
return (
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
<div className="flex items-center gap-2">
<MessageCircle className="size-3.5 text-rose-400" />
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Group Chat Settings
</span>
</div>
<FormField label="Selection Strategy" description="Determines which agent speaks next in each conversation turn.">
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
<Repeat className="size-3.5 text-[var(--color-text-muted)]" />
<span className="text-[13px] text-[var(--color-text-primary)]">Round Robin</span>
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">Only strategy</span>
</div>
</FormField>
<FormField label="Max Rounds" description="Maximum number of conversation turns across all agents before the group chat terminates.">
<input
type="number"
min={1}
max={100}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
placeholder="5"
value={settings.maxRounds}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
onChange({ ...settings, maxRounds: Number.isNaN(raw) ? 5 : Math.max(1, Math.min(100, raw)) });
}}
/>
</FormField>
</div>
);
}
/* ── Main orchestration mode panel ─────────────────────────── */
export function OrchestrationModePanel({
workflow,
onChange,
}: {
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
}) {
const [expanded, setExpanded] = useState(true);
const [scaffoldRequest, setScaffoldRequest] = useState<{
newMode: WorkflowOrchestrationMode;
fromLabel: string;
} | null>(null);
const currentMode = workflow.settings.orchestrationMode;
const inferredMode = useMemo(() => inferWorkflowOrchestrationMode(workflow), [workflow]);
const effectiveMode = currentMode ?? inferredMode;
const effectiveMeta = modeMap.get(effectiveMode);
const agentNodes = useMemo(
() => workflow.graph.nodes.filter((n) => n.kind === 'agent'),
[workflow.graph.nodes],
);
const handoffSettings: HandoffModeSettings = useMemo(
() => workflow.settings.modeSettings?.handoff ?? createDefaultModeSettings('handoff')!.handoff!,
[workflow.settings.modeSettings?.handoff],
);
const groupChatSettings: GroupChatModeSettings = useMemo(
() => workflow.settings.modeSettings?.groupChat ?? createDefaultModeSettings('group-chat')!.groupChat!,
[workflow.settings.modeSettings?.groupChat],
);
const applyModeChange = useCallback(
(newMode: WorkflowOrchestrationMode, restructure: boolean) => {
const modeSettings = createDefaultModeSettings(newMode);
const mergedSettings = {
...workflow.settings,
orchestrationMode: newMode,
modeSettings: modeSettings
? { ...workflow.settings.modeSettings, ...modeSettings }
: workflow.settings.modeSettings,
};
// Sync maxIterations from mode-specific settings for builder-based modes
if (newMode === 'group-chat' && modeSettings?.groupChat) {
mergedSettings.maxIterations = modeSettings.groupChat.maxRounds;
}
const base: WorkflowDefinition = { ...workflow, settings: mergedSettings };
if (restructure) {
const existingAgents = workflow.graph.nodes.filter((n) => n.kind === 'agent');
const newGraph = scaffoldGraphForMode(newMode, {
agentNodes: existingAgents.length > 0 ? existingAgents : undefined,
settings: mergedSettings,
});
onChange({ ...base, graph: newGraph });
} else {
onChange(syncBuilderModeEdgeIterations(base));
}
},
[workflow, onChange],
);
const handleModeSelect = useCallback(
(newMode: WorkflowOrchestrationMode) => {
if (newMode === currentMode) return;
const hasAgents = agentNodes.length > 0;
const fromLabel = currentMode
? (modeMap.get(currentMode)?.label ?? currentMode)
: `Auto (${modeMap.get(inferredMode)?.label ?? inferredMode})`;
if (hasAgents) {
setScaffoldRequest({ newMode, fromLabel });
} else {
applyModeChange(newMode, true);
}
},
[currentMode, inferredMode, agentNodes.length, applyModeChange],
);
const handleHandoffChange = useCallback(
(handoff: HandoffModeSettings) => {
onChange({
...workflow,
settings: {
...workflow.settings,
modeSettings: { ...workflow.settings.modeSettings, handoff },
},
});
},
[workflow, onChange],
);
const handleGroupChatChange = useCallback(
(groupChat: GroupChatModeSettings) => {
const updated: WorkflowDefinition = {
...workflow,
settings: {
...workflow.settings,
modeSettings: { ...workflow.settings.modeSettings, groupChat },
maxIterations: groupChat.maxRounds,
},
};
onChange(syncBuilderModeEdgeIterations(updated));
},
[workflow, onChange],
);
return (
<>
<div className="space-y-4">
{/* Section header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between"
aria-expanded={expanded}
>
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Orchestration Mode
</h4>
<div className="flex items-center gap-2">
{!currentMode && (
<span className="rounded-md bg-[var(--color-surface-3)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-muted)]">
Auto
</span>
)}
{effectiveMeta && (
<span className={`text-[11px] font-medium ${effectiveMeta.accentClass}`}>
{effectiveMeta.label}
</span>
)}
<ChevronDown
className={`size-3.5 text-[var(--color-text-muted)] transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
/>
</div>
</button>
{expanded && (
<div className="space-y-4">
{/* Mode cards */}
<div className="grid grid-cols-1 gap-2">
{modeOptions.map((option) => (
<ModeCard
key={option.value}
option={option}
selected={effectiveMode === option.value}
onClick={() => handleModeSelect(option.value)}
/>
))}
</div>
{/* Auto-inference indicator */}
{!currentMode && (
<InfoCallout>
Mode is auto-inferred from graph shape as <span className="font-medium text-[var(--color-text-primary)]">{effectiveMeta?.label}</span>.
Select a mode explicitly to lock it.
</InfoCallout>
)}
{/* Builder-based mode info */}
{isBuilderBasedMode(effectiveMode) && (
<InfoCallout>
In {effectiveMeta?.label} mode, the framework manages agent routing automatically. The graph defines participants.
</InfoCallout>
)}
{/* Graph-based mode info */}
{isGraphBasedMode(effectiveMode) && currentMode && (
<InfoCallout>
This mode uses the graph topology for execution. Connect agents with the appropriate edge types.
</InfoCallout>
)}
{/* Mode-specific settings */}
{effectiveMode === 'handoff' && (
<HandoffSettingsPanel
settings={handoffSettings}
agentNodes={agentNodes}
onChange={handleHandoffChange}
/>
)}
{effectiveMode === 'group-chat' && (
<GroupChatSettingsPanel
settings={groupChatSettings}
onChange={handleGroupChatChange}
/>
)}
</div>
)}
</div>
{/* Scaffold confirmation dialog */}
{scaffoldRequest && (
<ScaffoldDialog
fromMode={scaffoldRequest.fromLabel}
toMode={scaffoldRequest.newMode}
onAccept={() => {
applyModeChange(scaffoldRequest.newMode, true);
setScaffoldRequest(null);
}}
onDecline={() => {
applyModeChange(scaffoldRequest.newMode, false);
setScaffoldRequest(null);
}}
/>
)}
</>
);
}
@@ -0,0 +1,177 @@
import { useCallback } from 'react';
import { AlertCircle, Radio, Trash2 } from 'lucide-react';
import type {
RequestPortConfig,
WorkflowNode,
WorkflowNodeConfig,
WorkflowValidationIssue,
} from '@shared/domain/workflow';
interface RequestPortInspectorProps {
node: WorkflowNode;
validationIssues?: WorkflowValidationIssue[];
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
onNodeRemove: (nodeId: string) => void;
}
function InputField({
label,
value,
onChange,
multiline,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}) {
const base =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${base} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={base}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
)}
</label>
);
}
export function RequestPortInspector({
node,
validationIssues,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
}: RequestPortInspectorProps) {
const config = node.config as RequestPortConfig;
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
const patchConfig = useCallback(
(patch: Partial<RequestPortConfig>) => {
onNodeConfigChange(node.id, { ...config, ...patch });
},
[node.id, config, onNodeConfigChange],
);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-teal-500/10">
<Radio className="size-4 text-teal-400" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{node.label || 'Request Port'}
</div>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => onNodeRemove(node.id)}
title="Remove node"
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
{/* Label */}
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
{/* Port ID */}
<div className="space-y-1.5">
<InputField
label="Port ID"
onChange={(v) => patchConfig({ portId: v })}
placeholder="Unique port identifier"
value={config.portId}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Unique identifier used to target this port from parent workflows or external callers.
</p>
</div>
{/* Request Type */}
<div className="space-y-1.5">
<InputField
label="Request Type"
onChange={(v) => patchConfig({ requestType: v })}
placeholder="e.g. string, boolean, number, json"
value={config.requestType}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
Supported types: string, boolean, number, json
</p>
</div>
{/* Response Type */}
<div className="space-y-1.5">
<InputField
label="Response Type"
onChange={(v) => patchConfig({ responseType: v })}
placeholder="e.g. string, boolean, number, json"
value={config.responseType}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
Supported types: string, boolean, number, json
</p>
</div>
{/* Prompt */}
<InputField
label="Prompt"
multiline
onChange={(v) => patchConfig({ prompt: v || undefined })}
placeholder="Optional question shown to the user"
value={config.prompt ?? ''}
/>
{/* Info callout */}
<div className="rounded-lg border border-teal-500/20 bg-teal-500/5 px-3 py-2 text-[12px] text-teal-400">
This node pauses workflow execution and requests input from the user. The response is
coerced to the specified response type before continuing.
</div>
{/* Validation issues */}
{nodeIssues.length > 0 && (
<div className="space-y-1">
{nodeIssues.map((issue, i) => (
<div
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
issue.level === 'error'
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
}`}
key={`${issue.field ?? 'v'}-${i}`}
>
<AlertCircle className="mt-0.5 size-3 shrink-0" />
<span>{issue.message}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,228 @@
import { useCallback } from 'react';
import { ExternalLink, GitBranch, Link, Pencil, Trash2 } from 'lucide-react';
import type {
SubWorkflowConfig,
WorkflowDefinition,
WorkflowNode,
WorkflowNodeConfig,
} from '@shared/domain/workflow';
interface SubWorkflowInspectorProps {
node: WorkflowNode;
workflows: ReadonlyArray<WorkflowDefinition>;
currentWorkflowId: string;
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
onNodeRemove: (nodeId: string) => void;
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
}
type SourceMode = 'reference' | 'inline';
function resolveSourceMode(config: SubWorkflowConfig): SourceMode {
if (config.workflowId) return 'reference';
if (config.inlineWorkflow) return 'inline';
return 'reference';
}
function InputField({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
<input
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
</label>
);
}
export function SubWorkflowInspector({
node,
workflows,
currentWorkflowId,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
onDrillIntoSubWorkflow,
}: SubWorkflowInspectorProps) {
const config = node.config as SubWorkflowConfig;
const sourceMode = resolveSourceMode(config);
const availableWorkflows = workflows.filter((wf) => wf.id !== currentWorkflowId);
const selectedWorkflow = config.workflowId
? workflows.find((wf) => wf.id === config.workflowId)
: undefined;
const handleModeChange = useCallback(
(mode: SourceMode) => {
if (mode === 'reference') {
onNodeConfigChange(node.id, { kind: 'sub-workflow', workflowId: config.workflowId });
} else {
onNodeConfigChange(node.id, {
kind: 'sub-workflow',
inlineWorkflow: config.inlineWorkflow,
});
}
},
[node.id, config.workflowId, config.inlineWorkflow, onNodeConfigChange],
);
const handleWorkflowSelect = useCallback(
(workflowId: string) => {
onNodeConfigChange(node.id, {
kind: 'sub-workflow',
workflowId: workflowId || undefined,
});
},
[node.id, onNodeConfigChange],
);
const handleDrillIn = useCallback(() => {
onDrillIntoSubWorkflow(node);
}, [node, onDrillIntoSubWorkflow]);
const inlineNodeCount = config.inlineWorkflow?.graph?.nodes?.length ?? 0;
const inlineEdgeCount = config.inlineWorkflow?.graph?.edges?.length ?? 0;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-500/10">
<GitBranch className="size-4 text-amber-400" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{node.label || 'Sub-Workflow'}
</div>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
onClick={() => onNodeRemove(node.id)}
title="Remove node"
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
{/* Label */}
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
{/* Source mode selector */}
<div className="space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Source</span>
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-0.5">
<button
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
sourceMode === 'reference'
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => handleModeChange('reference')}
type="button"
>
Reference
</button>
<button
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
sourceMode === 'inline'
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => handleModeChange('inline')}
type="button"
>
Inline
</button>
</div>
</div>
{/* Reference mode */}
{sourceMode === 'reference' && (
<div className="space-y-3">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Workflow
</span>
<select
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
onChange={(e) => handleWorkflowSelect(e.target.value)}
value={config.workflowId ?? ''}
>
<option value="">Select a workflow</option>
{availableWorkflows.map((wf) => (
<option key={wf.id} value={wf.id}>
{wf.name || 'Untitled'}
</option>
))}
</select>
</label>
{selectedWorkflow?.description && (
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{selectedWorkflow.description}
</p>
)}
<button
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!config.workflowId || !selectedWorkflow}
onClick={handleDrillIn}
type="button"
>
<ExternalLink className="size-3" />
Open Sub-Workflow
</button>
</div>
)}
{/* Inline mode */}
{sourceMode === 'inline' && (
<div className="space-y-3">
{config.inlineWorkflow ? (
<div className="flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-[12px] text-amber-400">
<Link className="size-3 shrink-0" />
<span>
{inlineNodeCount} node{inlineNodeCount !== 1 ? 's' : ''},{' '}
{inlineEdgeCount} edge{inlineEdgeCount !== 1 ? 's' : ''}
</span>
</div>
) : (
<p className="text-[12px] text-[var(--color-text-muted)]">
No inline workflow configured yet.
</p>
)}
<button
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10"
onClick={handleDrillIn}
type="button"
>
<Pencil className="size-3" />
Edit Inline Workflow
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,208 @@
import { useState } from 'react';
import { Check, Copy, Download, Upload, X } from 'lucide-react';
import type { WorkflowDefinition } from '@shared/domain/workflow';
type ExportFormat = 'yaml' | 'mermaid' | 'dot';
type ImportFormat = 'yaml' | 'json';
/* ── Export Modal ──────────────────────────────────────────── */
interface ExportModalProps {
format: ExportFormat;
content: string;
onClose: () => void;
}
export function ExportModal({ format, content, onClose }: ExportModalProps) {
const [copied, setCopied] = useState(false);
function handleCopy() {
void navigator.clipboard.writeText(content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="export-modal-title"
>
<div className="w-full max-w-lg rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)] p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 id="export-modal-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
Export {format.toUpperCase()}
</h3>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<pre className="max-h-80 overflow-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4 font-mono text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{content}
</pre>
<div className="mt-4 flex justify-end">
<button
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
onClick={handleCopy}
type="button"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
</div>
</div>
);
}
/* ── Import Modal ──────────────────────────────────────────── */
interface ImportModalProps {
onImport: (content: string, format: ImportFormat) => Promise<WorkflowDefinition>;
onClose: () => void;
}
export function ImportModal({ onImport, onClose }: ImportModalProps) {
const [content, setContent] = useState('');
const [format, setFormat] = useState<ImportFormat>('yaml');
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function handleImport() {
if (!content.trim()) {
setError('Please paste workflow content');
return;
}
setError(null);
setImporting(true);
try {
await onImport(content, format);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed');
} finally {
setImporting(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="import-modal-title"
>
<div className="w-full max-w-lg rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)] p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h3 id="import-modal-title" className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
Import Workflow
</h3>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={onClose}
type="button"
>
<X className="size-4" />
</button>
</div>
<div className="mb-3 flex gap-2">
{(['yaml', 'json'] as const).map((f) => (
<button
key={f}
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
format === f
? 'bg-[var(--color-accent)] text-white'
: 'bg-[var(--color-surface-3)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
onClick={() => setFormat(f)}
type="button"
>
{f.toUpperCase()}
</button>
))}
</div>
<textarea
className="min-h-48 w-full resize-y rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4 font-mono text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50"
onChange={(e) => setContent(e.target.value)}
placeholder={`Paste your ${format.toUpperCase()} workflow definition here...`}
value={content}
/>
{error && (
<p className="mt-2 text-[12px] text-[var(--color-status-error)]">{error}</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)] disabled:opacity-50"
disabled={importing || !content.trim()}
onClick={handleImport}
type="button"
>
<Upload className="size-3.5" />
{importing ? 'Importing…' : 'Import'}
</button>
</div>
</div>
</div>
);
}
/* ── Export Dropdown ───────────────────────────────────────── */
interface ExportDropdownProps {
onSelectFormat: (format: ExportFormat) => void;
onClose: () => void;
}
export function ExportDropdown({ onSelectFormat, onClose }: ExportDropdownProps) {
const formats: { value: ExportFormat; label: string }[] = [
{ value: 'yaml', label: 'YAML' },
{ value: 'mermaid', label: 'Mermaid' },
{ value: 'dot', label: 'DOT (Graphviz)' },
];
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div
className="absolute right-0 top-full z-50 mt-1 w-40 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] py-1 shadow-xl"
role="listbox"
>
{formats.map((f) => (
<button
key={f.value}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
onClick={() => { onSelectFormat(f.value); onClose(); }}
role="option"
aria-selected={false}
type="button"
>
<Download className="size-3" />
{f.label}
</button>
))}
</div>
</>
);
}
@@ -0,0 +1,340 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Panel,
MiniMap,
MarkerType,
useNodesState,
useEdgesState,
useReactFlow,
type Node,
type Edge,
type OnConnect,
type OnEdgesChange,
type OnNodesChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { LayoutGrid, Maximize2, ZoomIn, ZoomOut } from 'lucide-react';
import type { WorkflowDefinition, WorkflowGraph, WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { ModelDefinition } from '@shared/domain/models';
import { inferWorkflowOrchestrationMode } from '@shared/domain/workflow';
import {
addWorkflowEdge,
autoLayoutWorkflowGraph,
fromCanvasPositions,
isWorkflowConnectionAllowed,
removeWorkflowEdge,
toCanvasEdges,
toCanvasNodes,
type WorkflowGraphNodeData,
} from '@renderer/lib/workflowGraph';
import { workflowNodeTypes } from './WorkflowGraphNodes';
import { workflowEdgeTypes } from './WorkflowGraphEdges';
interface WorkflowGraphCanvasProps {
workflow: WorkflowDefinition;
availableModels?: ReadonlyArray<ModelDefinition>;
workflows?: ReadonlyArray<WorkflowDefinition>;
onGraphChange: (graph: WorkflowGraph) => void;
onNodeSelect: (nodeId: string | null) => void;
onEdgeSelect: (edgeId: string | null) => void;
selectedNodeId: string | null;
}
function WorkflowGraphCanvasInner({
workflow,
availableModels,
workflows,
onGraphChange,
onNodeSelect,
onEdgeSelect,
selectedNodeId,
}: WorkflowGraphCanvasProps) {
const reactFlow = useReactFlow();
const { fitView } = reactFlow;
const graph = workflow.graph;
const draggingRef = useRef(false);
const [zoomLevel, setZoomLevel] = useState(1);
const modeBadge = useMemo(() => {
const mode = inferWorkflowOrchestrationMode(workflow);
const explicit = !!workflow.settings.orchestrationMode;
const meta: Record<WorkflowOrchestrationMode, { label: string; icon: string; color: string }> = {
single: { label: 'Single', icon: '●', color: 'text-emerald-400' },
sequential: { label: 'Sequential', icon: '→', color: 'text-sky-400' },
concurrent: { label: 'Concurrent', icon: '⇉', color: 'text-amber-400' },
handoff: { label: 'Handoff', icon: '⇢', color: 'text-violet-400' },
'group-chat': { label: 'Group Chat', icon: '⟳', color: 'text-rose-400' },
};
return { mode, explicit, ...meta[mode] };
}, [workflow]);
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
toCanvasNodes(graph, availableModels, workflows),
);
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
toCanvasEdges(graph),
);
useEffect(() => {
setNodes(toCanvasNodes(graph, availableModels, workflows));
setEdges(toCanvasEdges(graph));
}, [graph, availableModels, workflows, setNodes, setEdges]);
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
(changes) => {
const removals = changes.filter((c) => c.type === 'remove');
const nonRemovals = changes.filter((c) => c.type !== 'remove');
if (removals.length > 0) {
let updatedGraph = graph;
for (const removal of removals) {
if (removal.type === 'remove') {
const node = graph.nodes.find((n) => n.id === removal.id);
if (node && node.kind !== 'start' && node.kind !== 'end') {
updatedGraph = {
...updatedGraph,
nodes: updatedGraph.nodes.filter((n) => n.id !== removal.id),
edges: updatedGraph.edges.filter((e) => e.source !== removal.id && e.target !== removal.id),
};
}
}
}
if (updatedGraph !== graph) {
onGraphChange(updatedGraph);
onNodeSelect(null);
}
}
if (nonRemovals.length > 0) {
onNodesChangeBase(nonRemovals);
}
const hasDragStart = nonRemovals.some(
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
);
const hasDragStop = nonRemovals.some(
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
);
if (hasDragStart) {
draggingRef.current = true;
}
if (hasDragStop && draggingRef.current) {
draggingRef.current = false;
setNodes((currentNodes) => {
const updatedGraph = fromCanvasPositions(workflow, currentNodes);
onGraphChange(updatedGraph);
return currentNodes;
});
}
},
[onNodesChangeBase, workflow, graph, onGraphChange, onNodeSelect, setNodes],
);
const handleEdgesChange: OnEdgesChange = useCallback(
(changes) => {
const removals = changes.filter((c) => c.type === 'remove');
if (removals.length > 0) {
let updatedGraph = graph;
for (const removal of removals) {
if (removal.type === 'remove') {
updatedGraph = removeWorkflowEdge(updatedGraph, removal.id);
}
}
onGraphChange(updatedGraph);
onEdgeSelect(null);
}
const nonRemovals = changes.filter((c) => c.type !== 'remove');
if (nonRemovals.length > 0) {
onEdgesChangeBase(nonRemovals);
}
},
[onEdgesChangeBase, graph, onGraphChange, onEdgeSelect],
);
const handleConnect: OnConnect = useCallback(
(connection) => {
if (!isWorkflowConnectionAllowed(connection, graph)) {
return;
}
if (connection.source && connection.target) {
const updatedGraph = addWorkflowEdge(graph, connection.source, connection.target);
onGraphChange(updatedGraph);
}
},
[graph, onGraphChange],
);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<WorkflowGraphNodeData>) => {
onNodeSelect(node.id);
onEdgeSelect(null);
},
[onNodeSelect, onEdgeSelect],
);
const handleEdgeClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
onEdgeSelect(edge.id);
onNodeSelect(null);
},
[onEdgeSelect, onNodeSelect],
);
const handlePaneClick = useCallback(() => {
onNodeSelect(null);
onEdgeSelect(null);
}, [onNodeSelect, onEdgeSelect]);
const handleAutoLayout = useCallback(() => {
const layouted = autoLayoutWorkflowGraph(graph);
onGraphChange(layouted);
requestAnimationFrame(() => fitView({ padding: 0.3 }));
}, [graph, onGraphChange, fitView]);
const handleZoomIn = useCallback(() => {
reactFlow.zoomIn();
}, [reactFlow]);
const handleZoomOut = useCallback(() => {
reactFlow.zoomOut();
}, [reactFlow]);
const handleFitView = useCallback(() => {
fitView({ padding: 0.3 });
}, [fitView]);
// Ctrl+A to select all nodes
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
setNodes((currentNodes) =>
currentNodes.map((n) => ({ ...n, selected: true })),
);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [setNodes]);
return (
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
<ReactFlow
nodes={nodes.map((n) => ({
...n,
selected: n.id === selectedNodeId,
}))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onPaneClick={handlePaneClick}
onMoveEnd={() => setZoomLevel(reactFlow.getZoom())}
nodeTypes={workflowNodeTypes}
edgeTypes={workflowEdgeTypes}
selectionOnDrag
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: 'default',
style: { stroke: '#6366f1', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
}}
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
deleteKeyCode="Delete"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
<Panel position="top-left">
<div className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 shadow-sm backdrop-blur">
<span className={`text-[12px] font-semibold leading-none ${modeBadge.color}`}>
{modeBadge.icon}
</span>
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
{modeBadge.label}
</span>
{!modeBadge.explicit && (
<span className="rounded bg-[var(--color-surface-3)] px-1.5 py-px text-[10px] text-[var(--color-text-muted)]">
auto
</span>
)}
</div>
</Panel>
<MiniMap
className="!rounded-lg !border !border-[var(--color-border)] !bg-[var(--color-surface-1)]"
maskColor="rgba(0,0,0,0.6)"
nodeColor="#3f3f46"
/>
<Panel position="top-right">
<button
type="button"
onClick={handleAutoLayout}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
title="Auto-layout nodes"
>
<LayoutGrid className="size-3.5" />
Auto layout
</button>
</Panel>
<Panel position="bottom-right">
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 p-1 shadow-sm backdrop-blur">
<button
type="button"
onClick={handleZoomOut}
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
title="Zoom out"
>
<ZoomOut className="size-3.5" />
</button>
<span className="min-w-[3rem] text-center text-[11px] font-medium text-[var(--color-text-secondary)]">
{Math.round(zoomLevel * 100)}%
</span>
<button
type="button"
onClick={handleZoomIn}
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
title="Zoom in"
>
<ZoomIn className="size-3.5" />
</button>
<div className="mx-0.5 h-4 w-px bg-[var(--color-border)]" />
<button
type="button"
onClick={handleFitView}
className="flex size-7 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
title="Fit view"
>
<Maximize2 className="size-3.5" />
</button>
</div>
</Panel>
</ReactFlow>
</div>
);
}
export function WorkflowGraphCanvas(props: WorkflowGraphCanvasProps) {
return (
<ReactFlowProvider>
<WorkflowGraphCanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,14 @@
import { BaseEdge, type EdgeProps } from '@xyflow/react';
function SelfLoopEdge({ id, sourceX, sourceY, style, markerEnd }: EdgeProps) {
const loopHeight = 60;
const loopWidth = 40;
const path = `M ${sourceX} ${sourceY} C ${sourceX + loopWidth} ${sourceY - loopHeight}, ${sourceX - loopWidth} ${sourceY - loopHeight}, ${sourceX} ${sourceY}`;
return <BaseEdge id={id} path={path} style={style} markerEnd={markerEnd} />;
}
export const workflowEdgeTypes = {
selfLoop: SelfLoopEdge,
} as const;

Some files were not shown because too many files have changed in this diff Show More