mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f85cce418 | ||
|
|
33773c868f | ||
|
|
507bd5408c | ||
|
|
7e37d75116 | ||
|
|
fd9cc03c5c | ||
|
|
8797186a73 | ||
|
|
573efff647 | ||
|
|
b583f39a38 | ||
|
|
3a639813a9 | ||
|
|
0a5fa81111 | ||
|
|
778c3b4a5b | ||
|
|
366200b29d | ||
|
|
cbc05c8f08 | ||
|
|
ecba1be92a | ||
|
|
3a71baf104 | ||
|
|
a3e0bd9244 | ||
|
|
d265db42e5 | ||
|
|
ad2eab73d7 | ||
|
|
e79cbe8df9 | ||
|
|
1ac1cc1d47 | ||
|
|
bf72315735 | ||
|
|
59170a794d | ||
|
|
3564df78d3 | ||
|
|
a68c9ee837 | ||
|
|
5ac6980b3f | ||
|
|
f4cb4c768b | ||
|
|
e265714225 | ||
|
|
805e369b67 | ||
|
|
69d0804161 | ||
|
|
059714326a | ||
|
|
b302ea5979 | ||
|
|
81eb8f7c82 | ||
|
|
e47835c1e8 | ||
|
|
55df0c3b61 | ||
|
|
4971dcf9fc | ||
|
|
f05ec8ac7f | ||
|
|
69ac454f29 | ||
|
|
7a32c9c0a3 | ||
|
|
871609db5b | ||
|
|
41e74c2fa9 | ||
|
|
ea9444ddac | ||
|
|
8f6830dca1 | ||
|
|
f011311514 | ||
|
|
19a764d297 | ||
|
|
4c3198a550 | ||
|
|
c0aaffa39f | ||
|
|
e39fffaf3b | ||
|
|
10316a2871 | ||
|
|
a18803758b | ||
|
|
6eadb36c10 | ||
|
|
bcbdd2ef29 | ||
|
|
5d69d9d855 | ||
|
|
aa7830f01a | ||
|
|
36b37e8915 | ||
|
|
dc69f8bf04 | ||
|
|
6f7cf60aa9 | ||
|
|
423d45fa1b | ||
|
|
13bcc44f1a | ||
|
|
1ceb3d5669 | ||
|
|
1dd13588a0 | ||
|
|
235ddf7e56 | ||
|
|
d7004ec2a9 | ||
|
|
0aed6240b9 | ||
|
|
11b36827f5 | ||
|
|
b434dd86b4 | ||
|
|
c2a691774a | ||
|
|
78949c5efd | ||
|
|
fb3e80ec47 | ||
|
|
5a71539705 | ||
|
|
906433f408 | ||
|
|
44d0ab07db | ||
|
|
49933f218b | ||
|
|
63acd9e87e | ||
|
|
ebb506a359 | ||
|
|
b25da56f5e | ||
|
|
30ff81009a | ||
|
|
9a261780c6 | ||
|
|
9b7e4dd6e9 | ||
|
|
4bc2c327f7 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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 */}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: ==, !=, >, <, contains, matches. Combine with && 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
Reference in New Issue
Block a user