Merge branch 'dev/rig-migration' of gitlab.com:samnasbo/shared/galaxy

This commit is contained in:
Ryan Ward
2026-08-18 11:30:18 -05:00
297 changed files with 47942 additions and 13733 deletions
+67 -31
View File
@@ -40,39 +40,54 @@ Environment variables:
- Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to enable Bedrock diagnostic output, including: - Set `GALAXY_BEDROCK_DIAGNOSTICS=1` to enable Bedrock diagnostic output, including:
- `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails) - `Error_<timestamp>.txt` snapshot files written to the repository root on request/stream failures (includes the serialized Bedrock context window, tool definitions, protobuf request debug payload, captured Bedrock diagnostic lines, and log tails)
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory - Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
- Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation.
### AI Provider Architecture ### AI Provider Architecture
Galaxy supports multiple AI backends via a **provider dispatch pattern**. Provider selection Galaxy supports session-owned ACP backends and direct model providers. Direct-provider selection is
is controlled by settings (`ai.openai.enabled` takes priority over `ai.bedrock.enabled`). controlled by settings (`ai.openai.enabled` takes priority over `ai.bedrock.enabled`), but provider
configuration never selects lifecycle ownership.
``` ```
Provider dispatch: response_stream.rs → resolve_provider_config() → ProviderConfig enum Direct provider: controller.rs → prepare_provider_run() → ProviderRunCoordinator
↓ BedrockOpenAI one model call per turn
bedrock/translator.rs openai/translator.rs AgentRuntime implementation
↓ tool batch
correlated action execution
↓ committed results
next ProviderRun turn
ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifecycle)
``` ```
**Shared types** in `app/src/ai/provider/`: **Durable direct-provider run**:
- `crates/galaxy_agent_core/src/provider_run.rs` — Serializable `ProviderRun` state machine, run/epoch identity, bounded model retries, ordered tool batches, cancellation, and terminal outcomes
- `app/src/ai/runtime/provider_run_coordinator.rs` — Drives one-turn `AgentRuntime` calls, validates model events, projects output, and commits exact tool lifecycle events
- `app/src/ai/runtime/rig.rs` — Builds base/CLI request profiles and resolves the configured one-turn runtime; it does not own follow-through
- `app/src/ai/runtime/rig_request.rs` — Converts controller request state into provider-neutral `TurnRequest` history, tools, prompts, and MCP aliases
- `app/src/ai/runtime/event_translator.rs` — Projects provider-neutral runtime events into Warp response events for UI/history compatibility
- `app/src/ai/blocklist/controller.rs` — Retains active runs, correlates actions by `(conversation_id, run_id, epoch, call_id)`, monitors commands, persists checkpoints, and restores interrupted runs
- `app/src/ai/blocklist/controller/response_stream.rs` — Owns ACP transport and shared UI/history projection only; it must not drive direct-provider retries or follow-up turns
**Shared provider types** in `app/src/ai/provider/`:
- `types.rs``ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition` - `types.rs``ConversationMessage`, `MessageRole`, `MessageContent`, `ContentPart`, `ToolDefinition`
- `mod.rs``ProviderConfig` enum (Bedrock | OpenAI | None) - `mod.rs``ProviderConfig` enum (Bedrock | OpenAI | None)
**Bedrock provider** in `app/src/ai/bedrock/`: **Bedrock provider** in `app/src/ai/bedrock/`:
- `translator.rs` — Orchestrator: takes `api::Request` + config, returns `ResponseStream` - `runtime.rs` — Native one-call `AgentRuntime` over `ConverseStream`, including cancellation, reasoning signatures, token usage, and stop/error classification
- `request_translator.rs`Converts Warp proto → Bedrock SDK types (messages, system prompt, tools, sanitization) - `request_translator.rs`Shared Bedrock message sanitization and tool definitions
- `response_translator.rs` — Converts Bedrock stream events → Warp proto `ResponseEvent`s - `response_translator.rs` — Compatibility conversion helpers used by tests and background flows
- `convert.rs`Re-exports shared types + Bedrock SDK type builders - `convert.rs`Bedrock request construction and prompt-caching behavior
- `client.rs` — AWS SDK client construction and `converse_stream` call - `client.rs` — AWS SDK client construction, runtime creation, and independent background streaming calls
- `models.rs` — Model registry and cross-region inference prefix logic - `models.rs` — Model registry and cross-region inference prefix logic
- `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels) - `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels)
- `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`) - `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`)
- `external_config.rs` — Fallback config from Claude Code/OpenCode settings - `external_config.rs` — Fallback config from Claude Code/OpenCode settings
**OpenAI/LiteLLM provider** in `app/src/ai/openai/`: **OpenAI-compatible providers**:
- `translator.rs` — Orchestrator: same pattern as Bedrock, targets OpenAI chat completions API - Direct turns use one-call runtimes from `galaxy_agent_rig` selected in `app/src/ai/runtime/rig.rs` for OpenAI/LiteLLM, ChatGPT subscription, Anthropic, Gemini, and Vertex AI
- `client.rs``reqwest`-based HTTP client for `POST /v1/chat/completions` with streaming - `app/src/ai/openai/request_translator.rs` sanitizes provider-neutral history for OpenAI-compatible APIs
- `convert.rs``ConversationMessage` → OpenAI JSON format (system/user/assistant/tool roles, function calling) - `app/src/ai/openai/client.rs`, `convert.rs`, and `response_translator.rs` remain compatibility/background transport helpers, not lifecycle owners
- `request_translator.rs` — OpenAI-specific message sanitization (lighter than Bedrock's strict alternation rules)
- `response_translator.rs` — SSE stream parser → Warp proto `ResponseEvent`s
**Provider settings** (in settings TOML): **Provider settings** (in settings TOML):
- `ai.bedrock.enabled` — Use AWS Bedrock directly (default: true) - `ai.bedrock.enabled` — Use AWS Bedrock directly (default: true)
@@ -117,17 +132,38 @@ context_size = 128000
- Provider name shown as the description label in the model picker; icon shows OpenAI logo for all OpenAI-compatible providers - Provider name shown as the description label in the model picker; icon shows OpenAI logo for all OpenAI-compatible providers
Key invariants: Key invariants:
- Known tools are in `KNOWN_TOOLS` constant in `response_translator.rs` - Every direct-provider `AgentRuntime::start_turn` performs exactly one model call; only `ProviderRun` may schedule another turn or retry
- Tool definitions are built via `tool_definition_for_name()` in `convert_request.rs`; includes `recall_tool_history` for retrieving past tool results - Direct-provider model calls allow 120 seconds for stream startup and 300 seconds between stream events; either timeout is a recoverable transport failure that enters the existing bounded retry lifecycle with the same work identity
- Unknown/hallucinated tool calls are caught in the stream, paired with synthetic error results, and now emit a visible `AgentOutput` text message to the UI - Direct-provider remote telemetry records requested, started, retry-scheduled, and finished model-turn phases with explicit `llm_finished` state; root `provider_run_finished` records distinguish clean completion from failure or cancellation and mark the response stream terminal
- `recall_tool_history` is handled inline in the response translator (synthetic result from `messages_sent`) - `use_rig` and provider selection may choose request/transport details but must never choose lifecycle ownership
- Tool result archive: before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` extracts all tool_use/tool_result pairs into a separate `tool_result_archive` vec. `recall_tool_history` searches both live history + archived results, and supports a `tool_use_id` parameter for exact ID lookup - Direct-provider output may be projected through `ResponseStream`, but provider progress must not depend on response-stream result draining or `AfterStreamFinished`
- Prompt caching uses three cache points: system prompt, conversation history (second-to-last message), tool config - Direct-provider `RequestFileEdits` views must register from streaming output before provider-run completion; preprocessing results must survive delayed view registration, and `NotReady` retries must remain automatic rather than emitting a synthetic user permission decision
- A clean direct-provider `ProviderRunOutcome::Completed` explicitly finalizes the conversation as `Success` after terminal output projection, even if earlier turns added tool actions; child-completion waits rely on that status
- Provider actions and results must correlate by `(conversation_id, run_id, epoch, call_id)`; stale or duplicate callbacks must not advance a run
- Action status/result lookups and archived results are keyed by `(conversation_id, action_id)`; callers must supply the owning conversation and must not fall back to a global action-ID search
- Action blocked/executing/finished events carry `conversation_id`; UI subscribers must match it, and CLI shell-control mutations must also match the active block's requested-command action ID
- Active provider runs must checkpoint before external work, persist without credentials, validate deserialized run invariants before normalization or runtime construction, normalize unsafe restored states, and reconcile command state before continuing; cancellation intent stays checkpointed until the terminal outcome is projected and `finish_active_provider_run` performs cleanup
- A restored `AwaitingModel` checkpoint has an uncertain remote outcome and must terminate as an explicit restore failure rather than replaying the call; known recoverable failures observed in-process retain the bounded model-retry lifecycle
- Same-conversation direct-provider follow-ups queue behind the cancelling generation; the old run keeps the active slot until terminal projection and cleanup, queued intent is checkpointed without credentials for restart recovery, and queued-only restore validates provider ownership and terminalizes the abandoned unprepared exchange from its persisted projection/stream identity before starting the successor; the next generation rebuilds provider history after cleanup so it includes the old generation's final committed output, and stale callbacks are ignored by stream identity
- Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs`
- Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose
- Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation
- `recall_tool_history` is an inline completed tool batch. `ProviderRun` commits its synthetic result and starts a bounded next turn without routing it through client action execution
- `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id`
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result` - `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
- `inject_input_messages_into_task()` and `extract_user_query_text()` ensure user queries persist for session restore - Progressive summaries are prepended to provider requests as a user/assistant pair; background summarization remains independent of the active provider run
- The stream emits a `UserQuery` proto message at the start of each response for conversation title - Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
- Progressive summary (if present) is prepended to the messages array as a user/assistant pair in `translator.rs` - Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run
- Loop prevention guardrail in `controller.rs` detects repeated tool failures (3+ identical) and injects corrective instructions - A direct-provider command completion is only queued when the terminal reports it; the CLI task remains active until the provider run applies that completion at a safe boundary and deactivates it
- Provider command ownership is resolved from the active slot or its durable snapshot by block/action identity; completion arriving during restore is persisted into that snapshot and must never fall back to the legacy assessment path
- Direct-provider completed-command assessments are hidden, tool-free root-task turns whose output and hidden input survive CLI-task deactivation and restoration
- ACP remains a separate session-owned runtime; direct-provider cleanup must not move ACP lifecycle into `ProviderRun`
- Orchestrated child conversations are leaf workers: nested `RunAgents` and legacy `StartAgent` calls are rejected before autonomy or permission bypasses, and child requests do not advertise delegation tools
- Direct-provider `RunAgents` remains pending until every local child reaches `Success`, `Error`, or `Cancelled`, or is removed/deleted; recoverable `Blocked`, `TransientError`, and `WaitingForEvents` states remain pending, and the hosted 30-second startup timeout must not apply to these completion waits
- `StartAgentWaitPolicy` is selected from child execution mode, not parent `run_id`: local children wait for completion and only remote/hosted children use startup acknowledgement
- Hosted `RunAgents` startup timeouts detach the exact `StartAgentRequestId`; late launch callbacks must not register the child after the timeout result, while children linked before cancellation remain independently running
### Platform Setup ### Platform Setup
- `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided. - `./script/bootstrap` - Platform-specific setup plus common agent skill installation from `skills-lock.json`; prompts for project/global when an install or update is needed unless a target flag or environment override is provided.
@@ -335,9 +371,9 @@ Behavior:
### Appearance Settings Notes ### Appearance Settings Notes
- Samsung-inspired built-in themes are available as `SamsungDark` and `SamsungLight`. - Galaxy's built-in brand themes are available as `GalaxyDark` and `GalaxyDay`.
- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel. - UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel.
- The one-click Samsung brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies: - The one-click Galaxy brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies:
- Samsung dark/light theme mapping - Galaxy Dark/Day system theme mapping
- terminal + AI font defaults - terminal + AI font defaults
- a best-available Samsung-style UI font fallback - the bundled, SIL Open Font License-licensed Roboto UI font
+67 -192
View File
@@ -1,215 +1,90 @@
# Contributing to Warp # Contributing to Galaxy
Thanks for helping improve Warp! This guide explains how to open issues, propose changes, and get your work reviewed. Thanks for helping improve Galaxy. This guide describes the local-first workflow for reporting
issues, developing changes, and preparing a reviewable contribution.
> [!TIP] ## Before you start
> **Chat with us in Slack.** Connect with other contributors and the Warp team in the [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) channel — a good place for ad-hoc questions, design discussion, and pairing with maintainers as you work through an issue or PR. New here? [Join the Warp Slack community](https://go.warp.dev/join-preview) first, then hop into `#oss-contributors`.
## TL;DR - Read [AGENTS.md](AGENTS.md) for the architecture, development commands, and coding conventions.
- Search the repository issue tracker before opening a duplicate report.
- For a bug, include reproduction steps, expected and actual behavior, platform details, and logs
or screenshots that do not contain secrets.
- For a larger feature, describe the user problem and agree on the shape of the change before
implementing it. Keep product and technical plans under `plans/` or `specs/` as appropriate.
- Report security vulnerabilities privately using [SECURITY.md](SECURITY.md). Do not publish
exploitable details in an issue or pull request.
- Bug fixes are welcome once the report is actionable from the provided details or maintainer triage. ## Development setup
- Feature requests must be marked `ready-to-spec` or `ready-to-implement` before PRs are accepted.
- Issues marked `warp:reserved-internal` are being handled by the Warp team and are not open for contributor PRs.
- Specs are the place where technical and design discussion on larger issues happen.
- Oz automatically triages incoming issues and reviews open PRs.
- Implementation PRs must include proof of manual testing.
## How Contributing to Warp Works
Warp's contribution model is shaped by [Oz](https://oz.warp.dev), an agent that automates parts of triage, spec writing, implementation, and review. Compared with a typical open-source repository, a few things work differently here:
- **Issues are the starting point for everything.** Discussion, scoping, and design happen on the issue before any PR is opened.
- **Feature requests differ from bug fixes:**
- Features are gated by readiness labels — `ready-to-spec`, then `ready-to-implement` once the design is settled — that signal when contributors can pick up the work. Discussion alone is not approval to begin work.
- Feature work needs a written spec first: feature requests go through a spec PR (a *product spec* + *tech spec* committed under [`specs/`](specs/)) before any code is written.
- Bug fixes can go straight to a code PR once the report is reproducible or otherwise actionable; they do not require spec PRs unless the scope or design is unclear.
- **Review is largely automated.** When you open a PR, Oz is auto-assigned and produces an initial review. Once Oz approves, it automatically requests a follow-up review from a Warp team subject-matter expert — you do not need to assign human reviewers yourself.
### Readiness labels
The Warp team applies one of the following labels when an issue is ready for contribution:
- **`ready-to-spec`** — The problem is understood but the design is open. Open a spec PR with a *product spec* (`product.md`) and a *tech spec* (`tech.md`) under [`specs/`](specs/) — see [Opening a Spec PR](#opening-a-spec-pr) for what goes in each. This label is **reserved for feature requests**.
- **`ready-to-implement`** — The issue is ready for a code PR. For bugs, this means the report is sufficiently reproducible or actionable and the likely fix does not need a spec, mocks, or deeper investigation.
- **`needs-mocks`** — Design mocks are required before implementation can begin. Wait for the Warp team to land them.
- **`warp:reserved-internal`** — The Warp team is reserving this work for internal implementation or alignment. Do not open a spec or code PR for issues with this label; Oz will reject contributor PRs linked to them with an explanatory comment.
Anyone can pick up a ready issue — readiness labels are not assignments, and the best implementation wins through normal review. If an issue has been sitting un-triaged or you'd like readiness re-evaluated, mention **@oss-maintainers** in a comment to flag it for the team.
## Contribution Flow
Steps owned by you (the contributor) are shown in yellow; steps owned by the Warp team or Oz are shown in blue.
```mermaid
flowchart TD
A[File an issue] --> B{Warp team triages}
B -- ready-to-spec<br/>(feature requests) --> C[Open spec PR<br/>product.md + tech.md]
B -- needs-mocks --> D[Design mocks produced]
D --> E[Open code PR]
C -- specs approved --> E
B -- ready-to-implement<br/>(actionable bugs or settled designs) --> E
E --> F[Oz review → SME review → CI → merge]
classDef contributor fill:#fef3c7,stroke:#b45309,color:#78350f;
classDef warpTeam fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
class A,C,E contributor;
class B,D,F warpTeam;
```
## Filing a Good Issue
Search [existing issues](https://github.com/warpdotdev/warp/issues) before filing to avoid duplicates. Use the issue templates when filing.
If you're already running Warp, the fastest way to file is the `/feedback` command — it opens a public GitHub issue with relevant context (logs, environment details) automatically attached.
### Bug reports
A good bug report includes:
- A clear title and a one-paragraph summary of the problem.
- Steps to reproduce (with a minimal example where possible).
- Expected vs. actual behavior.
- Warp version and OS (see `Settings → About`).
- Logs, screenshots, or screen recordings when relevant.
Once an issue is triaged as an actionable bug (by Oz's triage agent or a maintainer), it may be labeled **`ready-to-implement`** so you can pick it up and open a code PR.
### Feature requests
A good feature request describes the user-facing problem before any proposed implementation. Include:
- The user need or pain point, and who experiences it.
- The current behavior and why it falls short.
- A sketch of the desired behavior or workflow (a short example or mock is helpful but not required).
- Any relevant constraints (compatibility, related features, prior art, etc.).
Feature requests are the path that goes through the spec flow: a maintainer applies **`ready-to-spec`** when the problem is understood and the design is open for contributors. From there, the next step is a spec PR — not a code PR.
Automated triage may add informational labels (`area:*`, `repro:*`, etc.). Those do not affect readiness.
## Opening a Spec PR
Issues labeled `ready-to-spec` need a spec before code can begin. A spec consists of two short documents committed under [`specs/GH<issue-number>/`](specs/):
- **`product.md`** (the *product spec*) — Defines the desired behavior from the consumer's perspective (the user, an API caller, a CLI user, etc.) and stays out of implementation detail. The core is a numbered list of **testable behavior invariants** covering the happy path, user-visible states, inputs and responses, and edge cases (empty / error / loading, cancellation, offline, permission denied, races, accessibility). Optional sections: problem statement, goals / non-goals, Figma link, open questions.
- **`tech.md`** (the *tech spec*) — The implementation plan, grounded in this codebase. Required sections: **Context** (the current system and relevant files with line references), **Proposed changes** (modules touched, new types / APIs / state, data flow, tradeoffs), and **Testing and validation** (how each invariant from the product spec will be verified). Optional: end-to-end flow, Mermaid diagrams, risks, parallelization, follow-ups.
The spec-writing skills are sourced from [`warpdotdev/common-skills`](https://github.com/warpdotdev/common-skills), not authored directly in this repository. This checkout pins the expected versions in [`skills-lock.json`](skills-lock.json), and the bootstrap scripts can restore them for you:
- `./script/bootstrap` installs or updates common skills by default and prompts for a project-local or global install target when needed.
- `./script/bootstrap --install-common-skills-in-repo` installs the pinned common skills into this checkout's `.agents/skills/`.
- `./script/bootstrap --install-common-skills-globally` installs the pinned common skills into `~/.agents/skills/`.
- `WARP_COMMON_SKILLS_INSTALL_TARGET=project ./script/bootstrap` and `WARP_COMMON_SKILLS_INSTALL_TARGET=global ./script/bootstrap` select the same targets non-interactively.
- `./script/bootstrap --skip-common-skills` leaves common skills untouched if you are managing them separately.
To open a spec PR:
1. Add `specs/GH<issue-number>/product.md` and `specs/GH<issue-number>/tech.md`. See [`specs/GH408/`](specs/GH408/), [`specs/GH1063/`](specs/GH1063/), and [`specs/GH1066/`](specs/GH1066/) for examples of well-structured specs, and browse the rest of [`specs/`](specs/) for more. After common skills are installed, the `/write-product-spec` and `/write-tech-spec` skills are available to scaffold these for you.
2. Use the PR as the home for product and technical discussion.
3. Once the specs are approved, implementation generally continues on the same PR. In rarer cases — for example, if a large spec is merged on its own so the implementation can be broken up — it can move to a linked follow-up PR.
## Opening a Code PR
For issues labeled `ready-to-implement`:
1. Branch from `master`.
2. Implement the change and add tests (see [Testing](#testing)).
3. Run `./script/presubmit` and fix any failures before pushing.
4. Open a PR using the [pull request template](.github/pull_request_template.md) and add a changelog entry (`CHANGELOG-NEW-FEATURE`, `CHANGELOG-IMPROVEMENT`, or `CHANGELOG-BUG-FIX`); omit only for docs-only or refactoring-only changes.
5. Keep the PR focused on a single logical change and merge `master` in before the PR enters review.
You **do not need to manually request reviewers**. Oz is auto-assigned to PRs that target a ready issue and produces an initial review. After Oz approves, it automatically requests a follow-up review from the appropriate Warp team subject-matter expert.
After you push changes that address Oz's feedback, comment `/oz-review` on the PR to request a re-review — you can do this up to **three times** per PR. If something looks stuck or you need more reviews than that, mention **@oss-maintainers** on the PR to escalate to the team.
**You must include proof of [manual testing](#manual-testing)**. For small, isolated, and visual changes, you should include **before and after screenshots**. For larger, broad, or interactive changes, you should also include a **narrated screen recording**.
If a maintainer requests changes to your PR, you will need to request `/oz-review` again and pass it before a re-review can be requested. Oz will request the re-review for you automatically once you pass its reviews.
### PRs opened without a linked issue
We require PRs to be linked to an associated issue. This is where problems get scoped, [readiness labels](#readiness-labels) get applied, and some features go through a [spec phase](#opening-a-spec-pr) before any code is written. See the [Contribution Flow](#contribution-flow) for the full picture.
That said, if you open a PR ahead of the standard issue workflow, here's what we recommend:
First, **search for a related issue.** Due to the volume of issues we receive, there's often an existing issue for a given feature or bug fix. If you find one, link it in your PR description. Ideally, this issue will have been reviewed by a maintainer with a [readiness label](#readiness-labels) applied. If you do not find a related issue, file an issue describing what your PR resolves. Once a maintainer has reviewed the issue and associated PR, we can apply a readiness label to unblock final checks.
Then, **ensure your PR passes code review and includes relevant tests** per our [Opening a Code PR guide.](#opening-a-code-pr) If code review passes and relevant tests are present, that's high signal for us to review your work sooner.
## Using a Coding Agent
You can use **any coding agent** to implement a contribution — for example, Warp's built-in agent, Claude Code, Codex, Gemini CLI, or others — or no agent at all. This repository ships agent-readable context (skills under [`.agents/skills/`](.agents/skills/), specs under [`specs/`](specs/), and [`AGENTS.md`](AGENTS.md)) that any harness supporting these formats can pick up.
If you'd rather have an **Oz cloud agent** implement a ready issue for you, mention **@oss-maintainers** on the issue to request it. Approved requests run **for free** on complimentary Oz credits — you don't need to set up your own Oz account or pay for compute.
While you can use coding agents for implementation, we expect contributors to **collaborate with us personally**. This means that you should not be using agents like OpenClaw to engage in conversation with our team. Our maintainers will always talk to you as a human, so please talk to us as a human as well.
## Code Review
All pull requests go through a two-stage review process:
1. **Oz review** — When you open a PR, [Oz](https://warp.dev/oz) is automatically assigned and produces the first review. Oz checks for correctness, style, test coverage, and alignment with the linked issue and any associated specs.
2. **Warp team review** — Only after Oz has **approved** the PR is it routed to a Warp team subject-matter expert for a final human review. PRs that have not yet been approved by Oz will not be assigned to a team member.
You do not need to manually request reviewers at any stage. After pushing changes that address Oz's feedback, comment `/oz-review` on the PR to request a re-review — you can do this up to **three times** per PR. If something looks stuck or you need additional reviews, mention **@oss-maintainers** on the PR to escalate to the team.
### Stale PRs with requested changes
If a review (from Oz or a maintainer) leaves your PR with **changes requested** and it then goes quiet, automation follows up and eventually closes it so the review queue stays current. This applies only to external-contributor PRs with an active requested-changes review.
- **Reminders** are posted at **7** and **10** days of inactivity, with the **day-10 reminder serving as the final warning**.
- The PR is **automatically closed at ~14 days** of inactivity — but only after that final warning, so you always get a heads-up first.
- Only **your** activity resets the timer: pushing to your branch (including a force-push) or commenting on the PR. Maintainer comments don't reset it, since the PR is waiting on you.
- To keep a PR open, just push updates or reply. A closed PR can be reopened when you're ready to continue (reopen it and push, or ask a maintainer to reopen).
- Maintainers can apply the **`no-autoclose`** label to exempt a PR that should stay open (for example, when it's blocked on us).
## Development Setup
See [README.md](README.md) and [AGENTS.md](AGENTS.md) for the full engineering guide. Quick start:
```bash ```bash
./script/bootstrap # platform-specific setup ./script/bootstrap
cargo run # build and run Warp cargo run
./script/presubmit # fmt, clippy, and tests
``` ```
## Testing The project is a Rust workspace with the main client in `app/`, GalaxyUI in `crates/galaxyui/`,
and shared libraries under `crates/`. The default build must work without a Galaxy account or a
remote service.
Tests are required for most code changes: ## Making changes
### Manual Testing Keep changes focused and preserve the local-first boundaries:
Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. For small, isolated, and visual changes, you should include **before and after screenshots**. For larger, broad, or interactive changes, you should also include a **narrated screen recording**.
You can run the app locally using `./script/run` - see [AGENTS.md](AGENTS.md) for more details on how to get set up. - Model traffic may use only a provider explicitly configured by the user.
- Galaxy owns permission prompts and execution for shell, file, and MCP actions.
- Local content should use the local repository instead of cloud identity or sync services.
- New UI should reuse existing GalaxyUI components, theme tokens, and button themes.
- Never log credentials, prompts, command output, or provider responses unless a diagnostic mode
explicitly documents that behavior.
### Automated Tests For user-facing changes, verify the complete flow manually. Include screenshots for small visual
- **Bug fixes** should include a regression test that would have caught the bug. changes and a short recording for larger interactive flows when practical. For persistence,
- **Algorithmic or non-trivial logic** needs unit tests. provider, or agent-runtime changes, add restart, failure, cancellation, or boundary coverage as
- **User-facing flows** should have end-to-end coverage under [`crates/integration/`](crates/integration/) whenever the behavior can be exercised that way. The bar is high-quality coverage of the changes you ship — with agent-driven development the expectation is more integration tests, not just coverage of P0 paths. If a flow is worth shipping, it's usually worth an integration test. appropriate.
Run unit tests with `cargo nextest run`. ## Checks
## Code Style Run the focused checks for the code you touched, then run the required formatting and lint checks:
- `./script/format --check` and `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` must pass. ```bash
- Prefer imports over path qualifiers, inline format args (`println!("{x}")`), and exhaustive `match` over `_` wildcards. ./script/format
- See [AGENTS.md](AGENTS.md) for the full style guide, including WarpUI patterns and terminal model locking rules. cargo clippy --workspace --all-targets --all-features --tests -- -D warnings
cargo test
```
## Commit and Branch Conventions Useful narrower commands include:
- Branch names should be prefixed with your handle (e.g. `alice/fix-parser`). ```bash
- Commit messages should explain *what* and *why*, not just *what*. cargo check -p galaxy
cargo test -p galaxy --lib
cargo test -p galaxy_agent_core
cargo test -p galaxy_agent_rig
```
## Code of Conduct If a check cannot run locally, explain why in the change description and include the closest
available validation.
This project adopts the [Contributor Covenant](https://www.contributor-covenant.org/) (v2.1) as its code of conduct. All contributors and maintainers are expected to follow it in every project space. See [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) for the full text, or report violations to warp-coc at warp.dev. ## Pull requests
## Reporting Security Issues Use a branch named for the change, keep commits focused, and include:
See [`SECURITY.md`](SECURITY.md) for our security disclosure policy and private reporting channels. **Do not open public issues for security vulnerabilities.** 1. A concise summary of the user-visible behavior.
2. The design or architectural boundary affected.
3. Automated checks and manual verification performed.
4. Screenshots or recordings for visual and interactive changes.
5. Any follow-up work added to the relevant plan or noticed-bugs list.
## Getting Help Reviewers should be able to build the branch from a clean checkout and understand why the change
belongs in Galaxy. Do not include unrelated formatting or generated-file churn.
- Chat with other contributors and the Warp team in [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) on the [Warp Slack community](https://go.warp.dev/join-preview) (join the workspace first if you're new). ## Code of conduct
- Browse the [Warp docs](https://docs.warp.dev/).
- Open a [GitHub issue](https://github.com/warpdotdev/warp/issues) for bugs or feature requests. Galaxy follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Please keep issue, review, and
community discussions respectful, constructive, and focused on the work.
## License
The Galaxy application and most workspace crates are licensed under
[AGPL-3.0-only](LICENSE-AGPL). The GalaxyUI crates are licensed under the
[MIT License](LICENSE-MIT). Contributions are accepted under the license applicable to the code
they modify. Review the existing file headers and third-party notices before copying code into a
different crate.
Generated
+639 -5
View File
@@ -1025,6 +1025,12 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "as-any"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063"
[[package]] [[package]]
name = "as-raw-xcb-connection" name = "as-raw-xcb-connection"
version = "1.0.1" version = "1.0.1"
@@ -1327,7 +1333,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tungstenite", "tungstenite 0.24.0",
] ]
[[package]] [[package]]
@@ -1489,6 +1495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"untrusted 0.7.1",
"zeroize", "zeroize",
] ]
@@ -1531,6 +1538,32 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "aws-sdk-bedrock"
version = "1.150.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81186bb96a4e98ff93f7b4336deec0afc1f90ca282099fc5395fcf5de6c0389d"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-schema",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand 2.5.0",
"http 0.2.12",
"http 1.5.0",
"regex-lite",
"tracing",
]
[[package]] [[package]]
name = "aws-sdk-bedrockruntime" name = "aws-sdk-bedrockruntime"
version = "1.138.0" version = "1.138.0"
@@ -1739,17 +1772,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
dependencies = [ dependencies = [
"aws-smithy-async", "aws-smithy-async",
"aws-smithy-protocol-test",
"aws-smithy-runtime-api", "aws-smithy-runtime-api",
"aws-smithy-types", "aws-smithy-types",
"bytes",
"h2", "h2",
"http 1.5.0", "http 1.5.0",
"http-body 1.1.0",
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-util", "hyper-util",
"indexmap 2.14.0",
"pin-project-lite", "pin-project-lite",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"rustls-pki-types", "rustls-pki-types",
"serde",
"serde_json",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower", "tower",
@@ -1776,6 +1815,25 @@ dependencies = [
"aws-smithy-runtime-api", "aws-smithy-runtime-api",
] ]
[[package]]
name = "aws-smithy-protocol-test"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f76511a0e223ce78deb6a78b8afebda99cb737cfbc8a58d96dcb190f012dd40a"
dependencies = [
"assert-json-diff",
"aws-smithy-runtime-api",
"base64-simd",
"cbor-diag",
"ciborium",
"http 0.2.12",
"pretty_assertions",
"regex-lite",
"roxmltree 0.14.1",
"serde_json",
"thiserror 2.0.19",
]
[[package]] [[package]]
name = "aws-smithy-query" name = "aws-smithy-query"
version = "0.62.0" version = "0.62.0"
@@ -2692,6 +2750,25 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "cbor-diag"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
dependencies = [
"bs58",
"chrono",
"data-encoding",
"half",
"nom 7.1.3",
"num-bigint",
"num-rational",
"num-traits",
"separator",
"url",
"uuid",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.0" version = "1.4.0"
@@ -3373,6 +3450,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -4371,6 +4457,12 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]] [[package]]
name = "difflib" name = "difflib"
version = "0.4.0" version = "0.4.0"
@@ -5558,7 +5650,7 @@ dependencies = [
[[package]] [[package]]
name = "galaxy" name = "galaxy"
version = "2.1.0" version = "3.0.0"
dependencies = [ dependencies = [
"addr", "addr",
"aha-reqwest-eventsource", "aha-reqwest-eventsource",
@@ -5582,6 +5674,7 @@ dependencies = [
"async-trait", "async-trait",
"aws-config", "aws-config",
"aws-credential-types", "aws-credential-types",
"aws-sdk-bedrock",
"aws-sdk-bedrockruntime", "aws-sdk-bedrockruntime",
"aws-sdk-sts", "aws-sdk-sts",
"aws-smithy-types", "aws-smithy-types",
@@ -5653,6 +5746,8 @@ dependencies = [
"futures-util", "futures-util",
"fuzzy_match", "fuzzy_match",
"galaxy_acp", "galaxy_acp",
"galaxy_agent_core",
"galaxy_agent_rig",
"galaxy_cli", "galaxy_cli",
"galaxy_completer", "galaxy_completer",
"galaxy_core", "galaxy_core",
@@ -5855,7 +5950,11 @@ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-channel", "async-channel",
"async-io", "async-io",
"async-stream",
"async-trait",
"base64 0.22.1",
"futures", "futures",
"galaxy_agent_core",
"log", "log",
"serde", "serde",
"serde_json", "serde_json",
@@ -5863,6 +5962,37 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
] ]
[[package]]
name = "galaxy_agent_core"
version = "0.1.0"
dependencies = [
"async-channel",
"async-trait",
"futures",
"serde",
"serde_json",
]
[[package]]
name = "galaxy_agent_rig"
version = "0.1.0"
dependencies = [
"async-stream",
"async-trait",
"aws-sdk-bedrockruntime",
"aws-smithy-http-client",
"base64 0.22.1",
"bytes",
"futures",
"galaxy_agent_core",
"rig-bedrock",
"rig-core",
"rig-vertexai",
"serde_json",
"tokio",
"uuid",
]
[[package]] [[package]]
name = "galaxy_cli" name = "galaxy_cli"
version = "0.0.0" version = "0.0.0"
@@ -7102,6 +7232,241 @@ dependencies = [
"gl_generator", "gl_generator",
] ]
[[package]]
name = "google-cloud-aiplatform-v1"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c4b46c4e50f6a09b85dae39be560c263a20ec06c2b0d5a96d65423936bf238f"
dependencies = [
"async-trait",
"bytes",
"google-cloud-api",
"google-cloud-gax",
"google-cloud-gax-internal",
"google-cloud-iam-v1",
"google-cloud-location",
"google-cloud-longrunning",
"google-cloud-lro",
"google-cloud-rpc",
"google-cloud-type",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
"tracing",
]
[[package]]
name = "google-cloud-api"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19dd5722ba4d24fbc19f6a44b88c335852c9a98d058bc0d6073c9a730c026cad"
dependencies = [
"bytes",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
]
[[package]]
name = "google-cloud-auth"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd"
dependencies = [
"async-trait",
"aws-lc-rs",
"base64 0.22.1",
"bytes",
"chrono",
"google-cloud-gax",
"hex",
"hmac 0.13.0",
"http 1.5.0",
"jsonwebtoken",
"reqwest 0.13.4",
"rustc_version",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.19",
"time",
"tokio",
"url",
]
[[package]]
name = "google-cloud-gax"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f"
dependencies = [
"bytes",
"futures",
"google-cloud-rpc",
"google-cloud-wkt",
"http 1.5.0",
"pin-project",
"rand 0.10.2",
"serde",
"serde_json",
"thiserror 2.0.19",
"tokio",
]
[[package]]
name = "google-cloud-gax-internal"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb04c54317ace06d489213f761797240b3046142a9b7ce6b9a82a9d134e193d1"
dependencies = [
"bytes",
"futures",
"google-cloud-auth",
"google-cloud-gax",
"google-cloud-rpc",
"google-cloud-wkt",
"h2",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper",
"lazy_static",
"opentelemetry",
"opentelemetry-semantic-conventions",
"opentelemetry_sdk",
"percent-encoding",
"pin-project",
"prost",
"prost-types",
"reqwest 0.13.4",
"rustc_version",
"serde",
"serde_json",
"thiserror 2.0.19",
"tokio",
"tokio-stream",
"tonic",
"tonic-prost",
"tower",
"tracing",
"tracing-opentelemetry",
]
[[package]]
name = "google-cloud-iam-v1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34cdf5acc7ef946ee2db7a7f62bd436d8395a6543b4beef110cdc061fcf578bb"
dependencies = [
"async-trait",
"bytes",
"google-cloud-gax",
"google-cloud-gax-internal",
"google-cloud-type",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
"tracing",
]
[[package]]
name = "google-cloud-location"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "280d5acdba8fcb1232c0719ed788d85b7e362b82cbb425b7050d3ce46f075ede"
dependencies = [
"async-trait",
"bytes",
"google-cloud-gax",
"google-cloud-gax-internal",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
"tracing",
]
[[package]]
name = "google-cloud-longrunning"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6ce05df0aea2c08472983ce2bbbed9483cbb637b89ff69a7c4ef94371fe4f2"
dependencies = [
"async-trait",
"bytes",
"google-cloud-gax",
"google-cloud-gax-internal",
"google-cloud-rpc",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
"tracing",
]
[[package]]
name = "google-cloud-lro"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd7cca2b991d619525d72a170ca7f413cb520872702442da22ac9af650a8e786"
dependencies = [
"google-cloud-gax",
"google-cloud-gax-internal",
"google-cloud-longrunning",
"google-cloud-rpc",
"google-cloud-wkt",
"serde",
"tokio",
"tracing",
]
[[package]]
name = "google-cloud-rpc"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2162c08a89118130979ba261080e960e44cdcb2d6e2ab8ca9b1da245285d353"
dependencies = [
"bytes",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
]
[[package]]
name = "google-cloud-type"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63acc3a92a85f96bab021c3a3e29b53bbacc97651e1b524d4c2991960a63eb82"
dependencies = [
"bytes",
"google-cloud-wkt",
"serde",
"serde_json",
"serde_with 3.21.0",
]
[[package]]
name = "google-cloud-wkt"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7"
dependencies = [
"base64 0.22.1",
"bytes",
"serde",
"serde_json",
"serde_with 3.21.0",
"thiserror 2.0.19",
"time",
"url",
]
[[package]] [[package]]
name = "gpu-allocator" name = "gpu-allocator"
version = "0.28.0" version = "0.28.0"
@@ -7161,7 +7526,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror 1.0.69", "thiserror 1.0.69",
"tungstenite", "tungstenite 0.24.0",
"ws_stream_wasm", "ws_stream_wasm",
] ]
@@ -7688,6 +8053,19 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.6.0" version = "0.6.0"
@@ -8617,6 +8995,22 @@ dependencies = [
"uuid-simd", "uuid-simd",
] ]
[[package]]
name = "jsonwebtoken"
version = "10.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"serde",
"serde_json",
"signature",
"zeroize",
]
[[package]] [[package]]
name = "kasuari" name = "kasuari"
version = "0.4.12" version = "0.4.12"
@@ -10750,6 +11144,42 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "opentelemetry"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.19",
"tracing",
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47"
[[package]]
name = "opentelemetry_sdk"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.5",
"thiserror 2.0.19",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -11513,6 +11943,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -12735,6 +13175,89 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "rig-bedrock"
version = "0.41.0"
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
dependencies = [
"async-stream",
"aws-config",
"aws-sdk-bedrockruntime",
"aws-smithy-types",
"base64 0.22.1",
"futures",
"rig-core",
"rig-derive",
"schemars 1.2.2",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-futures",
"uuid",
]
[[package]]
name = "rig-core"
version = "0.41.0"
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
dependencies = [
"as-any",
"async-stream",
"base64 0.22.1",
"bytes",
"eventsource-stream",
"fastrand 2.5.0",
"futures",
"futures-timer",
"glob",
"http 1.5.0",
"indexmap 2.14.0",
"mime",
"mime_guess",
"ordered-float 5.3.0",
"pin-project-lite",
"reqwest 0.13.4",
"rig-derive",
"schemars 1.2.2",
"serde",
"serde_json",
"thiserror 2.0.19",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-futures",
"url",
]
[[package]]
name = "rig-derive"
version = "0.41.0"
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
dependencies = [
"convert_case 0.11.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "rig-vertexai"
version = "0.41.0"
source = "git+https://github.com/0xPlaygrounds/rig.git?rev=1f9547774edb4c269be991ac42eb043fd7b6e87f#1f9547774edb4c269be991ac42eb043fd7b6e87f"
dependencies = [
"base64 0.22.1",
"google-cloud-aiplatform-v1",
"google-cloud-auth",
"rig-core",
"serde",
"serde_json",
"thiserror 2.0.19",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -12745,7 +13268,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.2.17", "getrandom 0.2.17",
"libc", "libc",
"untrusted", "untrusted 0.9.0",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@@ -12850,6 +13373,15 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "roxmltree"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b"
dependencies = [
"xmlparser",
]
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.20.0" version = "0.20.0"
@@ -13185,7 +13717,7 @@ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"untrusted", "untrusted 0.9.0",
] ]
[[package]] [[package]]
@@ -13433,6 +13965,12 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "separator"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
[[package]] [[package]]
name = "seq-macro" name = "seq-macro"
version = "0.3.6" version = "0.3.6"
@@ -15220,6 +15758,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.28.0",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -15379,6 +15933,44 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"base64 0.22.1",
"bytes",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"rustls-native-certs",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -15387,9 +15979,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap 2.14.0",
"pin-project-lite", "pin-project-lite",
"slab",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -15492,6 +16087,20 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
dependencies = [
"js-sys",
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
"web-time",
]
[[package]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"
@@ -15583,6 +16192,25 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http 1.5.0",
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.19",
"utf-8",
]
[[package]] [[package]]
name = "twox-hash" name = "twox-hash"
version = "2.1.3" version = "2.1.3"
@@ -15870,6 +16498,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+8
View File
@@ -29,6 +29,8 @@ publish = false
[workspace.dependencies] [workspace.dependencies]
# Local workspace crates. This lets us reference them in other crates without specifying a path. # Local workspace crates. This lets us reference them in other crates without specifying a path.
galaxy_acp = { path = "crates/acp" } galaxy_acp = { path = "crates/acp" }
galaxy_agent_core = { path = "crates/galaxy_agent_core" }
galaxy_agent_rig = { path = "crates/galaxy_agent_rig" }
ai = { path = "crates/ai" } ai = { path = "crates/ai" }
app-installation-detection = { path = "crates/app-installation-detection" } app-installation-detection = { path = "crates/app-installation-detection" }
asset_cache = { path = "crates/asset_cache" } asset_cache = { path = "crates/asset_cache" }
@@ -136,6 +138,9 @@ async-stream = "0.3.5"
async-task = "4.2.0" async-task = "4.2.0"
async-trait = "0.1.89" async-trait = "0.1.89"
async-fs = "2.1.2" async-fs = "2.1.2"
aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-bedrock = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-smithy-http-client = { version = "1", features = ["test-util"] }
backtrace = "0.3.76" backtrace = "0.3.76"
base64 = "0.22" base64 = "0.22"
bincode = "1.3.3" bincode = "1.3.3"
@@ -257,6 +262,9 @@ reqwest = { version = "0.13", features = [
"stream", "stream",
] } ] }
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" } reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
rig-core = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-core" }
rig-bedrock = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-bedrock" }
rig-vertexai = { git = "https://github.com/0xPlaygrounds/rig.git", rev = "1f9547774edb4c269be991ac42eb043fd7b6e87f", package = "rig-vertexai" }
resvg = "0.47.0" resvg = "0.47.0"
rust-embed = { version = "8.7.0", features = ["include-exclude"] } rust-embed = { version = "8.7.0", features = ["include-exclude"] }
rustc-hash = "2.1.1" rustc-hash = "2.1.1"
+4 -4
View File
@@ -189,12 +189,12 @@ When adding/editing match statements, avoid using the wildcard _ when at all pos
### Appearance Settings Notes ### Appearance Settings Notes
- Samsung-inspired built-in themes are available as `SamsungDark` and `SamsungLight`. - Galaxy's built-in brand themes are available as `GalaxyDark` and `GalaxyDay`.
- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel. - UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel.
- The one-click Samsung brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies: - The one-click Galaxy brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies:
- Samsung dark/light theme mapping - Galaxy Dark/Day system theme mapping
- terminal + AI font defaults - terminal + AI font defaults
- a best-available Samsung-style UI font fallback - the bundled, SIL Open Font License-licensed Roboto UI font
### Configuration ### Configuration
+48 -95
View File
@@ -1,112 +1,65 @@
<a href="https://www.warp.dev"> # Galaxy
<img width="1024" alt="Warp Agentic Development Environment product preview" src="https://github.com/user-attachments/assets/9976b2da-2edd-4604-a36c-8fd53719c6d4" />
</a>
&nbsp;
<p align="center">
<a href="https://www.warp.dev"><img height="20" alt="Built with Warp" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/main/Github/Built-With-Warp-Export@2x.png" /></a>
&nbsp;
<a href="https://oz.warp.dev"><img height="20" alt="Powered by Oz" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/main/Github/Powered-By-Oz-Export@2x.png" /></a>
</p>
<p align="center"> Galaxy is a local-first developer terminal with an agent that works alongside your shell. It is
<a href="https://www.warp.dev">Website</a> designed to keep your conversations, settings, and personal workspace data on your machine while
· letting you explicitly connect the model providers and local agents you choose.
<a href="https://www.warp.dev/code">Code</a>
·
<a href="https://www.warp.dev/agents">Agents</a>
·
<a href="https://www.warp.dev/terminal">Terminal</a>
·
<a href="https://www.warp.dev/drive">Drive</a>
·
<a href="https://docs.warp.dev">Docs</a>
·
<a href="https://www.warp.dev/blog/how-warp-works">How Warp Works</a>
</p>
> [!NOTE] ## What Galaxy provides
> OpenAI is the founding sponsor of the new, open-source Warp repository, and the new agentic management workflows are powered by GPT models.
<h1></h1> - A fast terminal, editor, and local workspace for development.
- Agent conversations with Galaxy-owned permissions for shell, file, and MCP actions.
- Provider connections for ChatGPT subscriptions, OpenAI-compatible APIs, Anthropic, Gemini,
Vertex AI, AWS Bedrock, and ACP agent runtimes.
- Local Galaxy Drive content, including rules, profiles, notebooks, workflows, and MCP settings.
- Explicit network boundaries: model traffic goes only to configured providers, and network tools
remain opt-in.
## About ## Building from source
[Warp](https://www.warp.dev) is an agentic development environment, born out of the terminal. Use Warp's built-in coding agent, or bring your own CLI agent (Claude Code, Codex, Gemini CLI, and others). Galaxy is a Rust workspace. Platform setup and the common development tools are installed with:
## Installation ```bash
./script/bootstrap
```
You can [download Warp](https://www.warp.dev/download) and [read our docs](https://docs.warp.dev/) for platform-specific instructions. Then build or run the client with:
## Warp Contributions Overview Dashboard ```bash
cargo run
```
Explore [build.warp.dev](https://build.warp.dev) to: Before submitting changes, run the repository checks:
- Watch thousands of Oz agents triage issues, write specs, implement changes, and review PRs
- View top contributors and in-flight features
- Track your own issues with GitHub sign-in
- Click into active agent sessions in a web-compiled Warp terminal
## Oz for OSS ```bash
./script/format
cargo clippy --workspace --all-targets --all-features --tests -- -D warnings
cargo test
```
Maintaining a popular open-source project? [Apply for Oz credits](https://tally.so/r/LZWxqG) to explore [Oz for OSS](https://github.com/warpdotdev/oz-for-oss). See [AGENTS.md](AGENTS.md) for architecture notes, platform setup, coding conventions, and the
focused commands used by the project.
Oz for OSS is our partner program for bringing the same agentic open-source management workflows used in this repository to select partner repositories. We work directly with maintainers to implement workflows for issue triage, PR review, community management, and contributor coordination in a way that fits each project. ## Contributing
Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a change. Bug reports should include
reproduction steps, expected and actual behavior, platform details, and relevant logs or
screenshots that do not contain secrets. Security issues must be reported privately according to
[SECURITY.md](SECURITY.md).
UI changes should include manual verification, and visual changes should include before-and-after
screenshots when practical. Changes to local persistence, provider boundaries, or agent execution
should include focused automated coverage.
## Licensing ## Licensing
Warp's UI framework (the `warpui_core` and `warpui` crates) are licensed under the [MIT license](LICENSE-MIT). The Galaxy application and most workspace crates are licensed under
[AGPL-3.0-only](LICENSE-AGPL). The GalaxyUI crates are licensed under the
[MIT License](LICENSE-MIT). Individual third-party components retain the licenses required by
their respective notices and source files.
The rest of the code in this repository is licensed under the [AGPL v3](LICENSE-AGPL). ## Project direction
## Open Source & Contributing The migration plan in [plans/galaxy-local-first-rig.md](plans/galaxy-local-first-rig.md) records the
local-first architecture, provider boundary, and remaining work. Contributions should preserve
Warp's client codebase is open source and lives in this repository. We welcome community contributions and have designed a lightweight workflow to help new contributors get started. For the full contribution flow, read our [CONTRIBUTING.md](CONTRIBUTING.md) guide. those boundaries: no inherited Warp service is required for a fresh install, and no provider or
agent may bypass Galaxy's permission and egress policy.
> [!TIP]
> **Chat with contributors and the Warp team** in the [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB) Slack channel — a good place for ad-hoc questions, design discussion, and pairing with maintainers. New here? [Join the Warp Slack community](https://go.warp.dev/join-preview) first, then jump into `#oss-contributors`.
### Issue to PR
Before filing, [search existing issues](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc) for your bug or feature request. If nothing exists, [file an issue](https://github.com/warpdotdev/warp/issues/new/choose) using our templates. Security vulnerabilities should be reported privately as described in [CONTRIBUTING.md](CONTRIBUTING.md#reporting-security-issues).
Once filed, a Warp maintainer reviews the issue and may apply a readiness label: [`ready-to-spec`](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+label%3Aready-to-spec) signals the design is open for contributors to spec out, and [`ready-to-implement`](https://github.com/warpdotdev/warp/issues?q=is%3Aissue+is%3Aopen+label%3Aready-to-implement) signals the design is settled and code PRs are welcome. Anyone can pick up a labeled issue — mention **@oss-maintainers** on an issue if you'd like it considered for a readiness label.
### Building the Repo Locally
To build and run Warp from source:
```bash
./script/bootstrap # platform-specific setup
./script/run # build and run Warp
./script/presubmit # fmt, clippy, and tests
```
See [AGENTS.md](AGENTS.md) for the full engineering guide, including coding style, testing, and platform-specific notes.
## Joining the Team
Interested in joining the team? See our [open roles](https://www.warp.dev/careers).
## Support and Questions
1. See our [docs](https://docs.warp.dev/) for a comprehensive guide to Warp's features.
2. Join our [Slack Community](https://go.warp.dev/join-preview) to connect with other users and get help from the Warp team — contributors hang out in [`#oss-contributors`](https://warpcommunity.slack.com/archives/C0B0LM8N4DB).
3. Try our [Preview build](https://www.warp.dev/download-preview) to test the latest experimental features.
4. Mention **@oss-maintainers** on any issue to escalate to the team — for example, if you encounter problems with the automated agents.
## Code of Conduct
We ask everyone to be respectful and empathetic. Warp follows the [Code of Conduct](CODE_OF_CONDUCT.md). To report violations, email warp-coc at warp.dev.
## Open Source Dependencies
We'd like to call out a few of the [open source dependencies](https://docs.warp.dev/help/licenses) that have helped Warp to get off the ground:
- [Tokio](https://github.com/tokio-rs/tokio)
- [NuShell](https://github.com/nushell/nushell)
- [Fig Completion Specs](https://github.com/withfig/autocomplete)
- [Warp Server Framework](https://github.com/seanmonstar/warp)
- [Alacritty](https://github.com/alacritty/alacritty)
- [Hyper HTTP library](https://github.com/hyperium/hyper)
- [FontKit](https://github.com/servo/font-kit)
- [Core-foundation](https://github.com/servo/core-foundation-rs)
- [Smol](https://github.com/smol-rs/smol)
+5 -2
View File
@@ -5,7 +5,7 @@ description = "Galaxy - AI-powered terminal"
edition = "2021" edition = "2021"
autobins = false autobins = false
name = "galaxy" name = "galaxy"
version = "2.1.0" version = "3.0.0"
publish.workspace = true publish.workspace = true
license.workspace = true license.workspace = true
@@ -234,6 +234,8 @@ warp_assets.workspace = true
warp_channel_config.workspace = true warp_channel_config.workspace = true
galaxy_completer.workspace = true galaxy_completer.workspace = true
galaxy_core.workspace = true galaxy_core.workspace = true
galaxy_agent_core.workspace = true
galaxy_agent_rig.workspace = true
galaxy_editor.workspace = true galaxy_editor.workspace = true
galaxy_graphql.workspace = true galaxy_graphql.workspace = true
galaxy_js = { workspace = true, optional = true } galaxy_js = { workspace = true, optional = true }
@@ -326,7 +328,8 @@ tracing-subscriber.workspace = true
# AWS SDK (loading credentials for BYO LLM) # AWS SDK (loading credentials for BYO LLM)
aws-config = { version = "1.8.16", features = ["credentials-login"] } aws-config = { version = "1.8.16", features = ["credentials-login"] }
aws-credential-types = "1" aws-credential-types = "1"
aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] } aws-sdk-bedrock.workspace = true
aws-sdk-bedrockruntime.workspace = true
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] } aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-smithy-types = "1" aws-smithy-types = "1"
aws-types = "1" aws-types = "1"
+33 -5
View File
@@ -1,4 +1,7 @@
use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION}; use galaxy_acp::{
resolve_known_acp_agent, AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION,
OPENCODE_NPM_VERSION,
};
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
use crate::persistence::model::AcpConversationData; use crate::persistence::model::AcpConversationData;
@@ -7,6 +10,14 @@ pub(crate) fn acp_model_id(agent_id: &str) -> String {
format!("acp:{}", agent_id.trim().to_ascii_lowercase()) format!("acp:{}", agent_id.trim().to_ascii_lowercase())
} }
pub(crate) fn acp_provider_model_id(provider_id: &str, agent_id: &str) -> String {
format!(
"acp:{}:{}",
provider_id.trim().to_ascii_lowercase(),
agent_id.trim().to_ascii_lowercase()
)
}
pub(crate) fn acp_selection_model_id( pub(crate) fn acp_selection_model_id(
agent_id: &str, agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>, values: &std::collections::BTreeMap<String, serde_json::Value>,
@@ -23,6 +34,21 @@ pub(crate) fn acp_selection_model_id(
} }
} }
pub(crate) fn acp_provider_selection_identity(
provider_id: &str,
agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
) -> String {
let mut identity = acp_provider_model_id(provider_id, agent_id);
for (key, value) in values {
identity.push(':');
identity.push_str(key);
identity.push('=');
identity.push_str(&canonical_json_value(value));
}
identity
}
pub(crate) fn acp_selection_identity( pub(crate) fn acp_selection_identity(
agent_id: &str, agent_id: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>, values: &std::collections::BTreeMap<String, serde_json::Value>,
@@ -53,7 +79,7 @@ fn canonical_json_value(value: &serde_json::Value) -> String {
), ),
serde_json::Value::Object(values) => { serde_json::Value::Object(values) => {
let mut entries = values.iter().collect::<Vec<_>>(); let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right)); entries.sort_by_key(|(key, _)| *key);
format!( format!(
"{{{}}}", "{{{}}}",
entries entries
@@ -215,9 +241,11 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() { match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(), "codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(), "opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
unknown => Err(format!( _ => resolve_known_acp_agent(agent_id).map_err(|error| {
"Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable" format!(
)), "{error} Configure a custom ACP executable if this client uses a different command."
)
}),
} }
} }
+3 -1
View File
@@ -4,7 +4,7 @@ use super::*;
fn unknown_builtin_agent_ids_are_rejected() { fn unknown_builtin_agent_ids_are_rejected() {
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err(); let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
assert!(error.contains("Unknown ACP agent preset")); assert!(error.contains("Unknown ACP agent"));
} }
#[test] #[test]
@@ -116,6 +116,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
let args = vec!["serve".to_owned()]; let args = vec!["serve".to_owned()];
let launch = resolve_acp_launch("custom", command, &args).unwrap(); let launch = resolve_acp_launch("custom", command, &args).unwrap();
let backend = AcpConversationData { let backend = AcpConversationData {
provider_id: String::new(),
agent_id: "custom".to_owned(), agent_id: "custom".to_owned(),
launch_fingerprint: acp_launch_fingerprint("custom", command, &args), launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
session_id: Some("session-123".to_owned()), session_id: Some("session-123".to_owned()),
@@ -139,6 +140,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
#[test] #[test]
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() { fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
let backend = AcpConversationData { let backend = AcpConversationData {
provider_id: String::new(),
agent_id: "codex".to_owned(), agent_id: "codex".to_owned(),
launch_fingerprint: String::new(), launch_fingerprint: String::new(),
session_id: Some("legacy-session".to_owned()), session_id: Some("legacy-session".to_owned()),
+5 -5
View File
@@ -7,17 +7,17 @@
mod launch; mod launch;
mod permissions; mod permissions;
mod prompt; mod prompt;
mod response_translator;
mod runtime_model; mod runtime_model;
mod transport; mod transport;
pub(crate) use launch::{ pub(crate) use launch::{
acp_launch_fingerprint, acp_model_id, acp_selection_identity, acp_selection_model_id, acp_launch_fingerprint, acp_model_id, acp_provider_selection_identity, acp_selection_identity,
resolve_acp_launch, validate_acp_dispatch, validate_acp_launch_identity, acp_selection_model_id, resolve_acp_launch, validate_acp_dispatch,
validate_acp_launch_identity,
}; };
pub(crate) use permissions::resolve_acp_permissions; pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel}; pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
pub(crate) use transport::{ pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionHandleSlot, acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
AcpSessionMetadata, AcpSteeringRequest, GalaxyMcpTarget, AcpTurnControlSlot, GalaxyMcpTarget,
}; };
+39 -11
View File
@@ -1,4 +1,5 @@
use galaxy_acp::{ContentBlock, ImageContent, TextContent}; use base64::Engine as _;
use galaxy_agent_core::{ContentPart, MessageContent};
use crate::ai::agent::api::RequestParams; use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult}; use crate::ai::agent::{AIAgentAttachment, AIAgentContext, AIAgentInput, MarkdownActionResult};
@@ -24,7 +25,7 @@ pub(super) struct GalaxyTerminalTools {
pub(super) fn prompt_content( pub(super) fn prompt_content(
params: &RequestParams, params: &RequestParams,
terminal_tools: GalaxyTerminalTools, terminal_tools: GalaxyTerminalTools,
) -> Result<Vec<ContentBlock>, String> { ) -> Result<MessageContent, String> {
let visible_query = params let visible_query = params
.input .input
.iter() .iter()
@@ -41,12 +42,15 @@ pub(super) fn prompt_content(
for item in context { for item in context {
match item { match item {
AIAgentContext::Image(image) => { AIAgentContext::Image(image) => {
let mut file_name = image.file_name.clone(); let data = base64::engine::general_purpose::STANDARD
params.redact_text_for_model(&mut file_name); .decode(&image.data)
images.push(ContentBlock::Image( .map_err(|error| {
ImageContent::new(image.data.clone(), image.mime_type.clone()) format!("failed to decode ACP image attachment: {error}")
.uri(format!("attachment://{file_name}")), })?;
)); images.push(ContentPart::Image {
data,
mime_type: image.mime_type.clone(),
});
} }
AIAgentContext::SelectedText(text) => { AIAgentContext::SelectedText(text) => {
hidden_context.push(format!("Selected text:\n{text}")); hidden_context.push(format!("Selected text:\n{text}"));
@@ -115,9 +119,14 @@ pub(super) fn prompt_content(
} }
params.redact_text_for_model(&mut text); params.redact_text_for_model(&mut text);
let mut prompt = vec![ContentBlock::Text(TextContent::new(text))]; if images.is_empty() {
prompt.extend(images); Ok(MessageContent::Text(text))
Ok(prompt) } else {
let mut parts = Vec::with_capacity(images.len() + 1);
parts.push(ContentPart::Text(text));
parts.extend(images);
Ok(MessageContent::MultiPart(parts))
}
} }
fn append_hidden_input( fn append_hidden_input(
@@ -144,6 +153,25 @@ fn append_hidden_input(
)); ));
} }
AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {} AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {}
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command,
..
} => {
hidden_context.push(format!(
"A monitored command has completed.\n\
Command: {}\n\
Galaxy block_id: {}\n\
Final output:\n{}\n\n{}",
completed_command.command,
completed_command.block_id,
tail_chars(
&completed_command.grid_contents,
MAX_RUNNING_COMMAND_OUTPUT_CHARS
),
prompt,
));
}
AIAgentInput::AutoCodeDiffQuery { query, .. } => { AIAgentInput::AutoCodeDiffQuery { query, .. } => {
hidden_context.push(format!( hidden_context.push(format!(
"Galaxy system request: create a code diff.\n{query}" "Galaxy system request: create a code diff.\n{query}"
+96 -50
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use galaxy_acp::ContentBlock; use galaxy_agent_core::{ContentPart, MessageContent};
use regex::Regex; use regex::Regex;
use serial_test::serial; use serial_test::serial;
@@ -42,6 +42,19 @@ fn user_query(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
} }
} }
fn prompt_text(prompt: &MessageContent) -> &str {
match prompt {
MessageContent::Text(text) => text,
MessageContent::MultiPart(parts) => match &parts[0] {
ContentPart::Text(text) => text,
_ => panic!("expected prompt text first"),
},
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. } => {
panic!("expected user prompt")
}
}
}
#[test] #[test]
fn keeps_images_as_native_acp_content() { fn keeps_images_as_native_acp_content() {
let mut params = RequestParams::new_for_test(); let mut params = RequestParams::new_for_test();
@@ -56,17 +69,19 @@ fn keeps_images_as_native_acp_content() {
)]; )];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt"); let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
assert_eq!(prompt.len(), 2); let MessageContent::MultiPart(parts) = &prompt else {
panic!("expected multipart prompt");
};
assert_eq!(parts.len(), 2);
assert!(matches!( assert!(matches!(
&prompt[0], &parts[0],
ContentBlock::Text(text) if text.text == "What is in this image?" ContentPart::Text(text) if text == "What is in this image?"
)); ));
assert!(matches!( assert!(matches!(
&prompt[1], &parts[1],
ContentBlock::Image(image) ContentPart::Image { data, mime_type }
if image.data == "aW1hZ2U=" if data == b"image"
&& image.mime_type == "image/png" && mime_type == "image/png"
&& image.uri.as_deref() == Some("attachment://screen.png")
)); ));
} }
@@ -80,13 +95,11 @@ fn sends_rules_and_selected_text_without_changing_visible_query() {
params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())]; params.global_rules = vec![("Safety".to_owned(), "Run tests first.".to_owned())];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt"); let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else { let text = prompt_text(&prompt);
panic!("expected text"); assert!(text.starts_with("Fix this"));
}; assert!(text.contains("hidden_from_transcript"));
assert!(text.text.starts_with("Fix this")); assert!(text.contains("broken()"));
assert!(text.text.contains("hidden_from_transcript")); assert!(text.contains("Run tests first."));
assert!(text.text.contains("broken()"));
assert!(text.text.contains("Run tests first."));
} }
#[test] #[test]
@@ -98,12 +111,50 @@ fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
}]; }];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt"); let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else { let text = prompt_text(&prompt);
panic!("expected text"); assert!(text.starts_with("Handle the Galaxy system request"));
}; assert!(text.contains("Repair the failing unit test."));
assert!(text.text.starts_with("Handle the Galaxy system request")); assert!(text.contains("hidden_from_transcript"));
assert!(text.text.contains("Repair the failing unit test.")); }
assert!(text.text.contains("hidden_from_transcript"));
#[test]
fn completed_command_assessment_uses_hidden_context_without_monitor_guidance() {
let block_id = BlockId::from("completed-session-42".to_owned());
let mut params = RequestParams::new_for_test();
params.input = vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Report whether the command succeeded.".to_owned(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_owned())]),
completed_command: RunningCommand {
command: "script/run-soak-test".to_owned(),
block_id: block_id.clone(),
grid_contents: "completed successfully".to_owned(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}];
let prompt = prompt_content(
&params,
GalaxyTerminalTools {
status: true,
interrupt: true,
},
)
.expect("prompt");
let text = prompt_text(&prompt);
assert!(text.starts_with("Handle the Galaxy system request"));
assert!(text.contains("hidden_from_transcript"));
assert!(text.contains("A monitored command has completed."));
assert!(text.contains("script/run-soak-test"));
assert!(text.contains(block_id.as_str()));
assert!(text.contains("Final output:\ncompleted successfully"));
assert!(text.contains("Report whether the command succeeded."));
assert!(text.contains("Selected text:\nroot context"));
assert!(!text.contains("galaxy_terminal_status"));
assert!(!text.contains("galaxy_terminal_interrupt"));
assert!(!text.contains("running_for_ms"));
} }
#[test] #[test]
@@ -135,16 +186,14 @@ fn running_command_identity_and_output_are_sent_as_hidden_context() {
}, },
) )
.expect("prompt"); .expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else { let text = prompt_text(&prompt);
panic!("expected text"); assert!(text.starts_with("Stop this after 75 seconds."));
}; assert!(text.contains(block_id.as_str()));
assert!(text.text.starts_with("Stop this after 75 seconds.")); assert!(text.contains("elapsed: 41s"));
assert!(text.text.contains(block_id.as_str())); assert!(text.contains("galaxy_terminal_status"));
assert!(text.text.contains("elapsed: 41s")); assert!(text.contains("running_for_ms"));
assert!(text.text.contains("galaxy_terminal_status")); assert!(text.contains("galaxy_terminal_interrupt_at"));
assert!(text.text.contains("running_for_ms")); assert!(text.contains("outside the model loop"));
assert!(text.text.contains("galaxy_terminal_interrupt_at"));
assert!(text.text.contains("outside the model loop"));
} }
#[test] #[test]
@@ -176,12 +225,10 @@ fn running_command_prompt_does_not_advertise_unavailable_mutations() {
}, },
) )
.expect("prompt"); .expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else { let text = prompt_text(&prompt);
panic!("expected text"); assert!(text.contains("galaxy_terminal_status"));
}; assert!(text.contains("no Galaxy terminal mutation tool"));
assert!(text.text.contains("galaxy_terminal_status")); assert!(!text.contains("galaxy_terminal_interrupt_at"));
assert!(text.text.contains("no Galaxy terminal mutation tool"));
assert!(!text.text.contains("galaxy_terminal_interrupt_at"));
} }
#[test] #[test]
@@ -224,20 +271,19 @@ fn redacts_request_text_before_creating_acp_content_blocks() {
}]; }];
let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt"); let prompt = prompt_content(&params, GalaxyTerminalTools::default()).expect("prompt");
let ContentBlock::Text(text) = &prompt[0] else { let MessageContent::MultiPart(parts) = &prompt else {
panic!("expected text"); panic!("expected multipart prompt");
}; };
assert!(!text.text.contains(SECRET)); let text = prompt_text(&prompt);
assert!(text.text.contains("******************")); assert!(!text.contains(SECRET));
assert!(text.text.contains("Selected text:")); assert!(text.contains("******************"));
assert!(text.text.contains("Current output:")); assert!(text.contains("Selected text:"));
assert!(text.text.contains("Attachment notes.txt:")); assert!(text.contains("Current output:"));
assert!(text.text.contains("Galaxy rules:")); assert!(text.contains("Attachment notes.txt:"));
assert!(text.contains("Galaxy rules:"));
assert!(matches!( assert!(matches!(
&prompt[1], &parts[1],
ContentBlock::Image(image) ContentPart::Image { data, .. } if data == b"image"
if image.data == "aW1hZ2U="
&& !image.uri.as_deref().unwrap_or_default().contains(SECRET)
)); ));
// Prompt redaction must not mutate the local transcript copy. // Prompt redaction must not mutate the local transcript copy.
-335
View File
@@ -1,335 +0,0 @@
use std::collections::HashMap;
use galaxy_acp::{AcpEvent, ContentBlock, StopReason, ToolCallId, ToolCallStatus};
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ResponseEvent};
use crate::ai::bedrock::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
build_user_query_message,
};
/// Stateful translation from ACP session updates to Galaxy's existing agent UI
/// response protocol.
pub(super) struct AcpResponseTranslator {
task_id: String,
request_id: String,
needs_create_task: bool,
user_query: Option<String>,
model_id: String,
initialized: bool,
message_id: Option<String>,
tool_titles: HashMap<ToolCallId, String>,
used_tokens: u64,
context_size: u64,
accept_next_user_content: bool,
}
impl AcpResponseTranslator {
pub(super) fn new(
task_id: String,
needs_create_task: bool,
user_query: Option<String>,
model_id: String,
) -> Self {
Self {
task_id,
request_id: Uuid::new_v4().to_string(),
needs_create_task,
user_query,
model_id,
initialized: false,
message_id: None,
tool_titles: HashMap::new(),
used_tokens: 0,
context_size: 0,
accept_next_user_content: false,
}
}
pub(super) fn translate(&mut self, event: AcpEvent) -> Result<Vec<ResponseEvent>, String> {
let mut events = Vec::new();
match event {
AcpEvent::SessionStarted { .. } => self.initialize(&mut events),
AcpEvent::AgentText { text } => {
self.initialize(&mut events);
self.add_or_append(&text, &mut events);
}
// Reasoning is deliberately not copied into the plain assistant
// transcript. ACP agents can still expose plans and tool progress.
AcpEvent::AgentThought { .. } => {}
AcpEvent::AgentContent { content, thought } => {
if !thought {
self.initialize(&mut events);
let description = match content {
ContentBlock::Text(text) => text.text,
ContentBlock::Image(_) => "[Agent returned an image.]".to_owned(),
ContentBlock::Audio(_) => "[Agent returned audio.]".to_owned(),
ContentBlock::ResourceLink(resource) => {
format!("[Agent referenced {}.]", resource.name)
}
ContentBlock::Resource(_) => {
"[Agent returned embedded resource content.]".to_owned()
}
_ => "[Agent returned unsupported content.]".to_owned(),
};
self.add_or_append(&description, &mut events);
}
}
AcpEvent::UserContent { content } => {
// Some ACP adapters replay user-message chunks while loading a
// session or echo Galaxy's initial prompt, which also contains
// hidden context. Only content explicitly authorized by the
// live-steering path may enter the visible transcript.
if self.accept_next_user_content {
self.accept_next_user_content = false;
self.initialize(&mut events);
if let ContentBlock::Text(text) = content {
events.push(build_user_query_message(&self.task_id, &text.text));
// Assistant output after steering belongs in a new chat
// bubble, not the message that preceded the follow-up.
self.message_id = None;
}
}
}
AcpEvent::ToolCall {
id,
title,
status,
output,
} => {
self.initialize(&mut events);
self.tool_titles.insert(id, title.clone());
self.add_or_append(&tool_status_line(&title, status), &mut events);
if let Some(output) = output {
self.add_or_append(&tool_output_block(&output), &mut events);
}
}
AcpEvent::ToolCallUpdate {
id,
title,
status,
output,
} => {
self.initialize(&mut events);
let title = title
.or_else(|| self.tool_titles.get(&id).cloned())
.unwrap_or_else(|| "tool".to_owned());
self.tool_titles.insert(id, title.clone());
if let Some(status) = status {
self.add_or_append(&tool_status_line(&title, status), &mut events);
}
if let Some(output) = output {
self.add_or_append(&tool_output_block(&output), &mut events);
}
}
AcpEvent::Usage { used, size, .. } => {
self.used_tokens = used;
self.context_size = size;
}
AcpEvent::PermissionRequested { request } => {
self.initialize(&mut events);
self.add_or_append(
&format!(
"\n\n> Permission requested for: {}\n",
request.tool_call.fields.title.as_deref().unwrap_or("tool")
),
&mut events,
);
}
AcpEvent::PermissionResolved { decision, .. } => {
self.initialize(&mut events);
self.add_or_append(
&format!("\n\n> Permission decision: {decision:?}\n"),
&mut events,
);
}
AcpEvent::Finished { stop_reason } => {
self.initialize(&mut events);
if self.message_id.is_none() && stop_reason != StopReason::Cancelled {
self.add_or_append(
"> ACP agent completed without a text response.",
&mut events,
);
}
events.push(self.finished(stop_reason));
}
AcpEvent::Error { message } => return Err(message),
// ACP events are forward-compatible. Unknown events do not belong
// in the user-visible transcript until Galaxy knows their meaning.
_ => {}
}
Ok(events)
}
pub(super) fn translate_steered_user_content(
&mut self,
content: ContentBlock,
) -> Result<Vec<ResponseEvent>, String> {
self.accept_next_user_content = true;
self.translate(AcpEvent::UserContent { content })
}
pub(super) fn steering_failed(&mut self, error: &str) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
&format!(
"Galaxy couldn't confirm that live steering message: {error}. \
The agent may not have received it; check the current terminal and file state \
before retrying."
),
&mut events,
);
// Any output still arriving from the original turn should not be
// appended to Galaxy's steering-failure notice.
self.message_id = None;
events
}
pub(super) fn steering_started_new_turn(&mut self) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
"The ACP adapter started that steering message as a separate turn instead of \
injecting it into the active one. Galaxy terminated the adapter process immediately, \
but the turn may have begun acting; check the current terminal and file state before \
retrying.",
&mut events,
);
self.message_id = None;
events
}
pub(super) fn startup_error(&mut self, error: &str) -> Vec<ResponseEvent> {
let mut events = Vec::new();
self.initialize(&mut events);
self.message_id = None;
self.add_or_append(
&format!("Galaxy couldn't start the ACP agent: {error}"),
&mut events,
);
events.push(self.finished(StopReason::Refusal));
events
}
fn initialize(&mut self, events: &mut Vec<ResponseEvent>) {
if self.initialized {
return;
}
// The ACP session ID is persisted separately. An empty conversation ID
// keeps this synthetic Init event out of Galaxy cloud token paths.
events.push(build_stream_init(&self.request_id, ""));
if self.needs_create_task {
events.push(build_create_task(&self.task_id));
}
if let Some(user_query) = &self.user_query {
events.push(build_user_query_message(&self.task_id, user_query));
}
self.initialized = true;
}
fn add_or_append(&mut self, text: &str, events: &mut Vec<ResponseEvent>) {
if text.is_empty() {
return;
}
if let Some(message_id) = &self.message_id {
events.push(build_append_text(&self.task_id, message_id, text));
} else {
let message_id = Uuid::new_v4().to_string();
events.push(build_add_agent_output_message(
&self.task_id,
&message_id,
text,
));
self.message_id = Some(message_id);
}
}
fn finished(&self, stop_reason: StopReason) -> ResponseEvent {
let reason = match stop_reason {
StopReason::EndTurn | StopReason::Cancelled => {
stream_finished::Reason::Done(stream_finished::Done {})
}
StopReason::MaxTokens | StopReason::MaxTurnRequests => {
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
}
StopReason::Refusal => stream_finished::Reason::Other(stream_finished::Other {}),
// ACP marks this enum non-exhaustive so newer agents can add stop reasons
// without breaking older clients.
_ => stream_finished::Reason::Other(stream_finished::Other {}),
};
let used_tokens = u32::try_from(self.used_tokens).unwrap_or(u32::MAX);
let context_usage = if self.context_size == 0 {
0.0
} else {
(self.used_tokens as f32 / self.context_size as f32).clamp(0.0, 1.0)
};
#[allow(deprecated)]
let usage_metadata = stream_finished::ConversationUsageMetadata {
context_window_usage: context_usage,
summarized: false,
credits_spent: 0.0,
platform_credits_spent: 0.0,
total_input_tokens: used_tokens,
token_usage: Vec::new(),
tool_usage_metadata: None,
warp_token_usage: HashMap::new(),
byok_token_usage: HashMap::new(),
custom_endpoint_token_usage: HashMap::new(),
context_window_segments: Vec::new(),
};
ResponseEvent {
r#type: Some(api::response_event::Type::Finished(
api::response_event::StreamFinished {
reason: Some(reason),
token_usage: vec![stream_finished::TokenUsage {
model_id: self.model_id.clone(),
// ACP reports current context occupancy, not the input
// consumed by this individual request. Galaxy separately
// accumulates per-request token usage, so counting it
// here would grow the total again on every turn.
total_input: 0,
output: 0,
input_cache_read: 0,
input_cache_write: 0,
cost_in_cents: 0.0,
}],
should_refresh_model_config: false,
request_cost: None,
conversation_usage_metadata: Some(usage_metadata),
},
)),
}
}
}
fn tool_status_line(title: &str, status: ToolCallStatus) -> String {
let status = match status {
ToolCallStatus::Pending => "waiting",
ToolCallStatus::InProgress => "running",
ToolCallStatus::Completed => "completed",
ToolCallStatus::Failed => "failed",
// ACP marks this enum non-exhaustive. Preserve a useful transcript if a
// newer agent reports a status this client does not recognize yet.
_ => "updated",
};
format!("\n\n> **{title}** — {status}\n")
}
fn tool_output_block(output: &str) -> String {
let mut block = String::from("\n");
for line in output.lines() {
block.push_str(" ");
block.push_str(line);
block.push('\n');
}
block
}
#[cfg(test)]
#[path = "response_translator_tests.rs"]
mod tests;
-356
View File
@@ -1,356 +0,0 @@
use galaxy_acp::{
AcpEvent, AgentCapabilities, ContentBlock, SessionId, StopReason, TextContent, ToolCallId,
ToolCallStatus,
};
use warp_multi_agent_api::{client_action, message, response_event};
use super::AcpResponseTranslator;
#[test]
fn initializes_the_existing_chat_exchange_and_persists_user_text() {
let mut translator = AcpResponseTranslator::new(
"task".to_owned(),
true,
Some("hello".to_owned()),
"acp:codex".to_owned(),
);
let events = translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
})
.expect("translate");
assert!(matches!(
events[0].r#type,
Some(response_event::Type::Init(_))
));
assert!(matches!(
events[1].r#type,
Some(response_event::Type::ClientActions(_))
));
assert!(matches!(
events[2].r#type,
Some(response_event::Type::ClientActions(_))
));
}
#[test]
fn streams_agent_text_as_add_then_append() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let first = translator
.translate(AcpEvent::AgentText {
text: "one".to_owned(),
})
.expect("first");
let second = translator
.translate(AcpEvent::AgentText {
text: " two".to_owned(),
})
.expect("second");
let Some(response_event::Type::ClientActions(first_actions)) = &first[1].r#type else {
panic!("expected first client action");
};
assert!(matches!(
first_actions.actions[0].action,
Some(client_action::Action::AddMessagesToTask(_))
));
let Some(response_event::Type::ClientActions(second_actions)) = &second[0].r#type else {
panic!("expected append client action");
};
assert!(matches!(
second_actions.actions[0].action,
Some(client_action::Action::AppendToMessageContent(_))
));
}
#[test]
fn renders_acp_tool_progress_as_text_not_an_executable_galaxy_action() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::ToolCall {
id: ToolCallId::from("tool-1"),
title: "Read file".to_owned(),
status: ToolCallStatus::InProgress,
output: None,
})
.expect("tool");
let Some(response_event::Type::ClientActions(actions)) = &events[1].r#type else {
panic!("expected client action");
};
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
panic!("expected display-only message");
};
assert!(matches!(
add.messages[0].message,
Some(message::Message::AgentOutput(_))
));
}
#[test]
fn maps_usage_and_successful_completion() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::Usage {
used: 25,
size: 100,
cost: None,
})
.expect("usage");
let events = translator
.translate(AcpEvent::Finished {
stop_reason: StopReason::EndTurn,
})
.expect("finished");
let Some(finished) = events.iter().find_map(|event| {
let Some(response_event::Type::Finished(finished)) = &event.r#type else {
return None;
};
Some(finished)
}) else {
panic!("expected finished");
};
assert_eq!(finished.token_usage[0].total_input, 0);
assert_eq!(
finished
.conversation_usage_metadata
.as_ref()
.expect("metadata")
.context_window_usage,
0.25
);
}
#[test]
fn renders_bounded_tool_output_in_the_agent_transcript() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::ToolCall {
id: ToolCallId::from("tool-1"),
title: "Run tests".to_owned(),
status: ToolCallStatus::Completed,
output: Some("test one ... ok\ntest two ... ok".to_owned()),
})
.expect("tool");
let Some(response_event::Type::ClientActions(status_actions)) = &events[1].r#type else {
panic!("expected status action");
};
let Some(client_action::Action::AddMessagesToTask(add_status)) =
&status_actions.actions[0].action
else {
panic!("expected status message");
};
let Some(message::Message::AgentOutput(status)) = &add_status.messages[0].message else {
panic!("expected agent output");
};
assert!(status.text.contains("Run tests"));
let Some(response_event::Type::ClientActions(output_actions)) = &events[2].r#type else {
panic!("expected output action");
};
assert!(matches!(
output_actions.actions[0].action,
Some(client_action::Action::AppendToMessageContent(_))
));
}
#[test]
fn successful_turn_without_agent_output_is_still_visible() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::Finished {
stop_reason: StopReason::EndTurn,
})
.expect("finished");
assert!(events.iter().any(|event| {
let Some(response_event::Type::ClientActions(actions)) = &event.r#type else {
return false;
};
let Some(client_action::Action::AddMessagesToTask(add)) = &actions.actions[0].action else {
return false;
};
let Some(message::Message::AgentOutput(output)) = &add.messages[0].message else {
return false;
};
output.text.contains("completed without a text response")
}));
}
#[test]
fn suppresses_unsolicited_user_content_so_initial_hidden_context_cannot_leak() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator
.translate(AcpEvent::UserContent {
content: ContentBlock::Text(TextContent::new(
"hidden initial prompt and system context",
)),
})
.expect("translate");
assert!(events.is_empty());
}
#[test]
fn live_steering_adds_a_user_bubble_and_starts_a_new_assistant_bubble() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::AgentText {
text: "original response".to_owned(),
})
.expect("initial output");
let steered = translator
.translate_steered_user_content(ContentBlock::Text(TextContent::new("stop at 75s")))
.expect("steering");
let Some(response_event::Type::ClientActions(user_actions)) = &steered[0].r#type else {
panic!("expected user client action");
};
let Some(client_action::Action::AddMessagesToTask(add_user)) = &user_actions.actions[0].action
else {
panic!("expected user message");
};
assert!(matches!(
add_user.messages[0].message,
Some(message::Message::UserQuery(_))
));
let resumed = translator
.translate(AcpEvent::AgentText {
text: "steered response".to_owned(),
})
.expect("resumed output");
let Some(response_event::Type::ClientActions(agent_actions)) = &resumed[0].r#type else {
panic!("expected agent client action");
};
assert!(matches!(
agent_actions.actions[0].action,
Some(client_action::Action::AddMessagesToTask(_))
));
}
#[test]
fn steering_failure_surfaces_an_indeterminate_delivery_warning() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
translator
.translate(AcpEvent::SessionStarted {
session_id: SessionId::from("session"),
agent_info: None,
capabilities: AgentCapabilities::default(),
can_load: true,
can_steer: true,
})
.expect("initialize");
let events = translator.steering_failed("turn is no longer active");
assert_eq!(events.len(), 1);
let Some(response_event::Type::ClientActions(error_actions)) = &events[0].r#type else {
panic!("expected visible error action");
};
let Some(client_action::Action::AddMessagesToTask(add_error)) =
&error_actions.actions[0].action
else {
panic!("expected visible error message");
};
let Some(message::Message::AgentOutput(output)) = &add_error.messages[0].message else {
panic!("expected agent output");
};
assert!(output.text.contains("couldn't confirm"));
assert!(output.text.contains("before retrying"));
}
#[test]
fn implicit_steering_turn_warning_does_not_recommend_a_blind_retry() {
let mut translator =
AcpResponseTranslator::new("task".to_owned(), false, None, "acp:codex".to_owned());
let events = translator.steering_started_new_turn();
let text =
events
.iter()
.filter_map(|event| match &event.r#type {
Some(response_event::Type::ClientActions(actions)) => actions
.actions
.iter()
.find_map(|action| match &action.action {
Some(client_action::Action::AddMessagesToTask(add)) => add
.messages
.iter()
.find_map(|message| match &message.message {
Some(message::Message::AgentOutput(output)) => {
Some(output.text.as_str())
}
_ => None,
}),
_ => None,
}),
_ => None,
})
.collect::<String>();
assert!(text.contains("started"));
assert!(text.contains("terminated"));
assert!(text.contains("immediately"));
assert!(text.contains("may have begun acting"));
}
#[test]
fn startup_error_keeps_the_user_request_and_finishes_visibly() {
let mut translator = AcpResponseTranslator::new(
"task".to_owned(),
false,
Some("help me".to_owned()),
"acp:codex".to_owned(),
);
let events = translator.startup_error("adapter missing");
assert_eq!(events.len(), 4);
assert!(matches!(
events[0].r#type,
Some(response_event::Type::Init(_))
));
let Some(response_event::Type::ClientActions(user_actions)) = &events[1].r#type else {
panic!("expected visible user request");
};
let Some(client_action::Action::AddMessagesToTask(user_messages)) =
&user_actions.actions[0].action
else {
panic!("expected user message");
};
assert!(matches!(
user_messages.messages[0].message,
Some(message::Message::UserQuery(_))
));
let Some(response_event::Type::ClientActions(error_actions)) = &events[2].r#type else {
panic!("expected visible startup error");
};
let Some(client_action::Action::AddMessagesToTask(error_messages)) =
&error_actions.actions[0].action
else {
panic!("expected error message");
};
let Some(message::Message::AgentOutput(output)) = &error_messages.messages[0].message else {
panic!("expected agent output");
};
assert!(output.text.contains("adapter missing"));
assert!(matches!(
events[3].r#type,
Some(response_event::Type::Finished(_))
));
}
+8 -15
View File
@@ -86,17 +86,12 @@ impl AcpRuntimeModel {
Ok(manager) Ok(manager)
} }
pub(crate) fn discovery_config(settings: &AISettings) -> Result<AcpManagerConfig, String> { pub(crate) fn discovery_config_for_values(
let agent_id = if settings.acp_agent_id.value().trim().is_empty() { agent_id: &str,
"codex" command: &str,
} else { args: &[String],
settings.acp_agent_id.value().trim() ) -> Result<AcpManagerConfig, String> {
}; let launch = crate::ai::acp::resolve_acp_launch(agent_id, command, args)?;
let launch = crate::ai::acp::resolve_acp_launch(
agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
)?;
Ok(AcpManagerConfig::new(launch)) Ok(AcpManagerConfig::new(launch))
} }
@@ -164,10 +159,8 @@ impl AcpRuntimeModel {
) -> BTreeMap<String, serde_json::Value> { ) -> BTreeMap<String, serde_json::Value> {
options options
.iter() .iter()
.filter_map(|option| { .filter(|option| !option.current_value.is_null())
(!option.current_value.is_null()) .map(|option| (option.id.clone(), option.current_value.clone()))
.then(|| (option.id.clone(), option.current_value.clone()))
})
.collect() .collect()
} }
+94 -175
View File
@@ -1,38 +1,27 @@
use std::collections::VecDeque;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::future::{BoxFuture, Fuse, FusedFuture as _};
use futures::stream::FusedStream as _;
use futures::{FutureExt as _, StreamExt as _}; use futures::{FutureExt as _, StreamExt as _};
use galaxy_acp::{ use galaxy_acp::{
AcpEvent, AcpPermissionPolicy, AcpRuntimeError, AcpSessionHandle, AcpSessionManager, AcpAgentRuntime, AcpAgentRuntimeConfig, AcpPermissionPolicy, AcpRuntimeState,
AcpSteeringOutcome, AcpTurnRequest, ContentBlock, McpServer, McpServerStdio, AcpRuntimeStateHandle, AcpSessionManager, McpServer, McpServerStdio, SessionConfigOptionValue,
SessionConfigOptionValue, SessionId, TextContent, SessionId,
};
use galaxy_agent_core::{
turn_control, AgentRuntime as _, RuntimeCapabilities, TurnCommand, TurnCommandSender,
TurnRequest,
}; };
use super::launch::acp_selection_identity; use super::launch::{acp_provider_selection_identity, acp_selection_identity};
use super::prompt::{prompt_content, GalaxyTerminalTools}; use super::prompt::{prompt_content, GalaxyTerminalTools};
use super::response_translator::AcpResponseTranslator;
use crate::ai::agent::api::{self, RequestParams}; use crate::ai::agent::api::{self, RequestParams};
use crate::ai::agent::EntrypointType; use crate::ai::agent::EntrypointType;
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
use crate::persistence::model::AcpConversationData; use crate::persistence::model::AcpConversationData;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
#[derive(Clone, Debug, Default)] pub(crate) type AcpSessionMetadata = AcpRuntimeState;
pub(crate) struct AcpSessionMetadata {
pub(crate) session_id: Option<String>,
pub(crate) can_load: bool,
pub(crate) can_steer: bool,
pub(crate) config_options: Vec<galaxy_acp::SessionConfigOption>,
}
#[derive(Clone, Debug)]
pub(crate) struct AcpSteeringRequest {
display_text: String,
model_text: String,
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GalaxyMcpTarget { pub(crate) struct GalaxyMcpTarget {
@@ -41,46 +30,25 @@ pub(crate) struct GalaxyMcpTarget {
pub(crate) pane_id: String, pub(crate) pane_id: String,
} }
impl AcpSteeringRequest { pub(crate) type AcpTurnControlSlot = Arc<Mutex<Option<TurnCommandSender>>>;
pub(crate) fn text(display_text: String, model_text: String) -> Self {
Self { struct AcpTurnControlGuard {
display_text, slot: AcpTurnControlSlot,
model_text,
}
}
} }
type SteeringResult = Result<AcpSteeringOutcome, AcpRuntimeError>; impl AcpTurnControlGuard {
type PendingSteering = Fuse<BoxFuture<'static, SteeringResult>>; fn new(slot: AcpTurnControlSlot, control: TurnCommandSender) -> Self {
if let Ok(mut active_control) = slot.lock() {
fn pending_steering(session: AcpSessionHandle, steering: AcpSteeringRequest) -> PendingSteering { *active_control = Some(control);
async move {
let content = ContentBlock::Text(TextContent::new(steering.model_text));
session.steer(vec![content]).await
}
.boxed()
.fuse()
}
pub(crate) type AcpSessionHandleSlot = Arc<Mutex<Option<AcpSessionHandle>>>;
struct AcpSessionHandleGuard {
slot: AcpSessionHandleSlot,
}
impl AcpSessionHandleGuard {
fn new(slot: AcpSessionHandleSlot, session: AcpSessionHandle) -> Self {
if let Ok(mut active_session) = slot.lock() {
*active_session = Some(session);
} }
Self { slot } Self { slot }
} }
} }
impl Drop for AcpSessionHandleGuard { impl Drop for AcpTurnControlGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Ok(mut active_session) = self.slot.lock() { if let Ok(mut active_control) = self.slot.lock() {
*active_session = None; *active_control = None;
} }
} }
} }
@@ -95,9 +63,8 @@ pub(crate) async fn acp_output_stream(
galaxy_terminal_interrupt_available: bool, galaxy_terminal_interrupt_available: bool,
permission_policy: AcpPermissionPolicy, permission_policy: AcpPermissionPolicy,
auto_approve_permissions: bool, auto_approve_permissions: bool,
session_metadata: Arc<Mutex<AcpSessionMetadata>>, session_metadata: AcpRuntimeStateHandle,
session_handle: AcpSessionHandleSlot, turn_control_slot: AcpTurnControlSlot,
steering_rx: async_channel::Receiver<AcpSteeringRequest>,
cancellation_rx: oneshot::Receiver<()>, cancellation_rx: oneshot::Receiver<()>,
) -> api::ResponseStream { ) -> api::ResponseStream {
let mut translator = response_translator(&params, &backend); let mut translator = response_translator(&params, &backend);
@@ -121,136 +88,69 @@ pub(crate) async fn acp_output_stream(
if let Some(server) = galaxy_mcp_server { if let Some(server) = galaxy_mcp_server {
mcp_servers.push(server); mcp_servers.push(server);
} }
let request = AcpTurnRequest { let runtime_id = if backend.provider_id.is_empty() {
config_values: backend acp_selection_identity(&backend.agent_id, &backend.config_values)
.config_values } else {
.into_iter() acp_provider_selection_identity(
.filter_map(|(key, value)| { &backend.provider_id,
serde_json::from_value::<SessionConfigOptionValue>(value) &backend.agent_id,
.ok() &backend.config_values,
.map(|value| (key, value)) )
})
.collect(),
conversation_key: conversation_id,
session_id: backend.session_id.map(SessionId::from),
cwd,
additional_directories: Vec::new(),
prompt,
mcp_servers,
auto_approve_permissions,
permission_policy,
prompt_capabilities: Default::default(),
}; };
let (session, events) = match manager.run_turn(request) { let mut runtime_config =
Ok(turn) => turn, AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
runtime_config.config_values = backend
.config_values
.iter()
.filter_map(|(key, value)| {
serde_json::from_value::<SessionConfigOptionValue>(value.clone())
.ok()
.map(|value| (key.clone(), value))
})
.collect();
runtime_config.session_id = backend.session_id.clone().map(SessionId::from);
runtime_config.mcp_servers = mcp_servers;
runtime_config.auto_approve_permissions = auto_approve_permissions;
runtime_config.permission_policy = permission_policy;
let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata);
let mut request = TurnRequest::new(runtime_id, Vec::new()).with_prompt(prompt);
request.conversation_id = Some(conversation_id);
let (control_sender, control) = turn_control();
let events = match runtime.start_turn(request, control).await {
Ok(events) => events,
Err(error) => return translated_startup_error_stream(translator, &error.to_string()), Err(error) => return translated_startup_error_stream(translator, &error.to_string()),
}; };
let session_handle_guard = AcpSessionHandleGuard::new(session_handle, session.clone()); let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone());
let stream = async_stream::stream! { let stream = async_stream::stream! {
let _session_handle_guard = session_handle_guard; let _turn_control_guard = turn_control_guard;
let mut cancellation_rx = cancellation_rx.fuse(); let mut cancellation_rx = cancellation_rx.fuse();
let mut events = Box::pin(events.fuse()); let mut events = events.fuse();
let mut steering_rx = Box::pin(steering_rx.fuse());
let mut steering_queue = VecDeque::new();
let mut steering_result: PendingSteering = Fuse::terminated();
loop { loop {
futures::select_biased! { futures::select_biased! {
_ = cancellation_rx => { _ = cancellation_rx => {
if let Err(error) = session.cancel().await { if let Err(error) = control_sender.try_send(TurnCommand::Cancel) {
log::warn!("Failed to cancel ACP turn cleanly: {error}"); log::warn!("Failed to queue ACP cancellation: {error}");
}
break;
}
steering = steering_rx.next() => {
let Some(steering) = steering else {
continue;
};
let content = ContentBlock::Text(TextContent::new(
steering.display_text.clone(),
));
match translator.translate_steered_user_content(content) {
Ok(response_events) => {
for response_event in response_events {
yield Ok(response_event);
}
}
Err(message) => {
yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow::anyhow!(message),
}));
break;
}
}
if steering_result.is_terminated() {
steering_result = pending_steering(session.clone(), steering);
} else {
steering_queue.push_back(steering);
}
}
steering = steering_result => {
match steering {
Ok(AcpSteeringOutcome::Injected) => {
// The user message was rendered as soon as Galaxy
// accepted it; keep consuming agent events without
// holding the transcript behind the steering RPC.
}
Ok(AcpSteeringOutcome::StartedNewTurn) => {
for response_event in translator.steering_started_new_turn() {
yield Ok(response_event);
}
}
Ok(AcpSteeringOutcome::Failed) => {
for response_event in translator.steering_failed(
"the ACP agent could not inject it into the active turn",
) {
yield Ok(response_event);
}
}
Err(error) => {
log::warn!("ACP live steering failed: {error}");
for response_event in translator.steering_failed(&error.to_string()) {
yield Ok(response_event);
}
}
}
steering_result = Fuse::terminated();
if let Some(steering) = steering_queue.pop_front() {
steering_result = pending_steering(session.clone(), steering);
} else if events.is_terminated() {
break;
} }
} }
event = events.next() => { event = events.next() => {
let Some(event) = event else { let Some(event) = event else {
if steering_result.is_terminated() && steering_queue.is_empty() { break;
};
let event = match event {
Ok(event) => event,
Err(error) => {
yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp",
source: anyhow::anyhow!(error),
}));
break; break;
} }
continue;
}; };
if let AcpEvent::SessionStarted {
session_id,
can_load,
can_steer,
..
} = &event
{
if let Ok(mut metadata) = session_metadata.lock() {
metadata.session_id = Some(session_id.to_string());
metadata.can_load = *can_load;
metadata.can_steer = *can_steer;
}
}
if let AcpEvent::ConfigOptions { options } = &event {
if let Ok(mut metadata) = session_metadata.lock() {
metadata.config_options = options.clone();
}
}
match translator.translate(event) { match translator.translate(event) {
Ok(response_events) => { Ok(response_events) => {
for response_event in response_events { for response_event in response_events {
yield Ok(response_event); yield Ok(api::StreamEvent::Response(response_event));
} }
} }
Err(message) => { Err(message) => {
@@ -279,18 +179,32 @@ pub(crate) fn acp_startup_error_stream(
fn response_translator( fn response_translator(
params: &RequestParams, params: &RequestParams,
backend: &AcpConversationData, backend: &AcpConversationData,
) -> AcpResponseTranslator { ) -> RuntimeResponseTranslator {
let task_id = params let task_id = params
.root_task_id .root_task_id
.clone() .clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let user_query = request_user_query(params); let user_query = request_user_query(params);
AcpResponseTranslator::new( RuntimeResponseTranslator::new(RuntimeResponseConfig {
task_id, task_id,
params.tasks.is_empty(), // ACP owns its session identifier. Keeping this empty prevents the
// compatibility Init event from entering Galaxy cloud-token paths.
conversation_id: String::new(),
needs_create_task: params.tasks.is_empty(),
user_query, user_query,
acp_selection_identity(&backend.agent_id, &backend.config_values), model_id: if backend.provider_id.is_empty() {
) acp_selection_identity(&backend.agent_id, &backend.config_values)
} else {
acp_provider_selection_identity(
&backend.provider_id,
&backend.agent_id,
&backend.config_values,
)
},
max_context_tokens: None,
capabilities: RuntimeCapabilities::session_runtime(),
empty_output_message: Some("> ACP agent completed without a text response.".to_owned()),
})
} }
fn request_user_query(params: &RequestParams) -> Option<String> { fn request_user_query(params: &RequestParams) -> Option<String> {
@@ -359,11 +273,16 @@ fn galaxy_mcp_args(
} }
fn translated_startup_error_stream( fn translated_startup_error_stream(
mut translator: AcpResponseTranslator, mut translator: RuntimeResponseTranslator,
message: &str, message: &str,
) -> api::ResponseStream { ) -> api::ResponseStream {
let events = translator.startup_error(message); let events =
Box::pin(futures::stream::iter(events.into_iter().map(Ok))) translator.startup_error(&format!("Galaxy couldn't start the ACP agent: {message}"));
Box::pin(futures::stream::iter(
events
.into_iter()
.map(|event| Ok(api::StreamEvent::Response(event))),
))
} }
#[cfg(test)] #[cfg(test)]
+56 -25
View File
@@ -14,13 +14,14 @@ pub use convert_from::{
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError, MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
}; };
use futures_lite::Stream; use futures_lite::Stream;
use galaxy_agent_core::ToolResult;
use galaxy_core::channel::ChannelState; use galaxy_core::channel::ChannelState;
use galaxy_core::execution_mode::AppExecutionMode; use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use galaxy_core::user_preferences::GetUserPreferences; use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, EntityId, SingletonEntity as _}; use galaxyui::{AppContext, EntityId, SingletonEntity as _};
use mcp::TemplatableMCPServerInfo; use mcp::TemplatableMCPServerInfo;
pub use r#impl::generate_multi_agent_output; pub(crate) use r#impl::prepare_direct_provider_params;
use serde::Serialize; use serde::Serialize;
use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions}; use super::{AIAgentInput, MCPContext, MCPServer, RequestMetadata, Suggestions};
@@ -36,6 +37,21 @@ use crate::settings::AISettings;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::workspaces::user_workspaces::UserWorkspaces; use crate::workspaces::user_workspaces::UserWorkspaces;
const INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA: &str =
"galaxy:internal-command-completion-assessment:v1";
pub(crate) fn mark_internal_command_completion_assessment(
message: &mut warp_multi_agent_api::Message,
) {
message.server_message_data = INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA.to_string();
}
pub(crate) fn is_internal_command_completion_assessment(
message: &warp_multi_agent_api::Message,
) -> bool {
message.server_message_data == INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA
}
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending /// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
/// requests that follow-up within a given conversation. /// requests that follow-up within a given conversation.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)] #[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)]
@@ -96,6 +112,8 @@ pub struct RequestParams {
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane. /// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
pub terminal_view_id: Option<EntityId>, pub terminal_view_id: Option<EntityId>,
pub input: Vec<AIAgentInput>, pub input: Vec<AIAgentInput>,
/// Normalized action results appended to direct-provider run history.
pub tool_results: Vec<ToolResult>,
pub conversation_token: Option<ServerConversationToken>, pub conversation_token: Option<ServerConversationToken>,
pub forked_from_conversation_token: Option<ServerConversationToken>, pub forked_from_conversation_token: Option<ServerConversationToken>,
pub ambient_agent_task_id: Option<AmbientAgentTaskId>, pub ambient_agent_task_id: Option<AmbientAgentTaskId>,
@@ -133,44 +151,55 @@ pub struct RequestParams {
pub research_agent_enabled: bool, pub research_agent_enabled: bool,
pub orchestration_enabled: bool, pub orchestration_enabled: bool,
pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>, pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>,
/// The root task ID for the conversation — needed for direct Bedrock streaming /// The root task ID used to anchor direct-provider projection when optimistic tasks are not
/// since optimistic tasks don't appear in the proto task_context. /// present in the proto task context.
pub root_task_id: Option<String>, pub root_task_id: Option<String>,
/// The conversation ID of the parent agent that spawned this child agent, if any. /// The conversation ID of the parent agent that spawned this child agent, if any.
pub parent_agent_id: Option<String>, pub parent_agent_id: Option<String>,
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator. /// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
pub agent_name: Option<String>, pub agent_name: Option<String>,
/// Full Bedrock conversation history for direct Bedrock calls. /// Provider-neutral conversation history for direct model calls.
/// When present, the Bedrock path uses this instead of extracting from task_context. pub message_history: Vec<crate::ai::provider::types::ConversationMessage>,
pub bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Progressive summary of older conversation history. Prepended as the first /// Progressive summary of older conversation history. Prepended as the first
/// message pair in the messages array sent to Bedrock. /// message pair in the messages array sent to the model.
pub bedrock_progressive_summary: Option<String>, pub progressive_summary: Option<String>,
/// Archived tool_use/tool_result pairs from previous summarization drains. /// Archived tool_use/tool_result pairs from previous summarization drains.
/// Passed to the Bedrock translator so `recall_tool_history` can search archived /// Kept separately so `recall_tool_history` can search archived results even after
/// results even after they've been summarized away from live history. /// they've been summarized away from live history.
pub bedrock_tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>, pub tool_result_archive: Vec<crate::ai::provider::types::ConversationMessage>,
/// Populated by the Bedrock path after building the message list. /// Populated while preparing a direct-provider run with the durable transcript that the
/// Contains the full messages sent (old history + new input) so the controller /// controller persists for restoration and future turns.
/// can store them back into the conversation for the next request cycle. pub messages_sent:
pub bedrock_messages_sent: std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel (AIFact/AIMemory). /// Global rules (name, content) from the local CloudModel (AIFact/AIMemory).
/// Injected into the system prompt when `is_memory_enabled` is true. /// Injected into the system prompt when `is_memory_enabled` is true.
pub global_rules: Vec<(String, String)>, pub global_rules: Vec<(String, String)>,
} }
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>; /// Response event projected into the local conversation controller.
#[derive(Debug)]
pub enum StreamEvent {
Response(warp_multi_agent_api::ResponseEvent),
}
pub type Event = Result<StreamEvent, Arc<AIApiError>>;
pub type LegacyEvent = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event> + Send + 'static>>; pub type ResponseStream = Pin<Box<dyn Stream<Item = Event> + Send + 'static>>;
#[cfg(not(target_family = "wasm"))]
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent> + Send + 'static>>;
// The WASM version of this type has no bound on `Send`, which is an unnecessary bound when // The WASM version of this type has no bound on `Send`, which is an unnecessary bound when
// targeting wasm because the browser is single-threaded (and we don't leverage WebWorkers for async // targeting wasm because the browser is single-threaded (and we don't leverage WebWorkers for async
// execution in WoW). // execution in WoW).
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
pub type ResponseStream = Pin<Box<dyn Stream<Item = Event>>>; pub type ResponseStream = Pin<Box<dyn Stream<Item = Event>>>;
#[cfg(target_family = "wasm")]
pub type LegacyResponseStream = Pin<Box<dyn Stream<Item = LegacyEvent>>>;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ConversationData { pub struct ConversationData {
pub id: AIConversationId, pub id: AIConversationId,
@@ -187,6 +216,7 @@ impl RequestParams {
Self { Self {
terminal_view_id: None, terminal_view_id: None,
input: vec![], input: vec![],
tool_results: vec![],
conversation_token: None, conversation_token: None,
forked_from_conversation_token: None, forked_from_conversation_token: None,
ambient_agent_task_id: None, ambient_agent_task_id: None,
@@ -218,10 +248,10 @@ impl RequestParams {
parent_agent_id: None, parent_agent_id: None,
agent_name: None, agent_name: None,
root_task_id: None, root_task_id: None,
bedrock_message_history: vec![], message_history: vec![],
bedrock_progressive_summary: None, progressive_summary: None,
bedrock_tool_result_archive: vec![], tool_result_archive: vec![],
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])), messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
global_rules: vec![], global_rules: vec![],
} }
} }
@@ -391,6 +421,7 @@ impl RequestParams {
Self { Self {
terminal_view_id, terminal_view_id,
input: request_input.all_inputs().cloned().collect(), input: request_input.all_inputs().cloned().collect(),
tool_results: Vec::new(),
conversation_token: conversation.server_conversation_token, conversation_token: conversation.server_conversation_token,
forked_from_conversation_token: conversation.forked_from_conversation_token, forked_from_conversation_token: conversation.forked_from_conversation_token,
ambient_agent_task_id: conversation.ambient_agent_task_id, ambient_agent_task_id: conversation.ambient_agent_task_id,
@@ -426,10 +457,10 @@ impl RequestParams {
.map(|id| id.to_string()), .map(|id| id.to_string()),
parent_agent_id: None, parent_agent_id: None,
agent_name: None, agent_name: None,
bedrock_message_history: Vec::new(), message_history: Vec::new(),
bedrock_progressive_summary: None, progressive_summary: None,
bedrock_tool_result_archive: Vec::new(), tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
global_rules: if is_memory_enabled { global_rules: if is_memory_enabled {
Self::load_global_rules(app) Self::load_global_rules(app)
} else { } else {
+12 -5
View File
@@ -23,6 +23,7 @@ use crate::ai::agent::api::convert_from::{
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage, convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage, MaybeAIAgentOutputMessage,
}; };
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::conversation::{ use crate::ai::agent::conversation::{
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata, update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
}; };
@@ -72,6 +73,7 @@ pub fn convert_conversation_data_to_ai_conversation(
let agent_conversation_data = match restoration_mode { let agent_conversation_data = match restoration_mode {
RestorationMode::Fork => AgentConversationData { RestorationMode::Fork => AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: usage_metadata, conversation_usage_metadata: usage_metadata,
reverted_action_ids: None, reverted_action_ids: None,
@@ -95,6 +97,7 @@ pub fn convert_conversation_data_to_ai_conversation(
}, },
RestorationMode::Continue => AgentConversationData { RestorationMode::Continue => AgentConversationData {
agent_backend: Default::default(), agent_backend: Default::default(),
active_provider_run_json: None,
server_conversation_token: Some( server_conversation_token: Some(
metadata.server_conversation_token.as_str().to_string(), metadata.server_conversation_token.as_str().to_string(),
), ),
@@ -389,17 +392,21 @@ impl ConvertToExchanges for &api::Task {
let added_message_as_exchange_input = match message { let added_message_as_exchange_input = match message {
api::message::Message::UserQuery(user_query) => { api::message::Message::UserQuery(user_query) => {
// Add user query as input if is_internal_command_completion_assessment(api_message) {
current_inputs.push(AIAgentInput::UserQuery { false
} else {
// Add user query as input
current_inputs.push(AIAgentInput::UserQuery {
query: user_query.query.clone(), query: user_query.query.clone(),
context: convert_input_context(user_query.context.as_ref()), context: convert_input_context(user_query.context.as_ref()),
static_query_type: None, static_query_type: None,
referenced_attachments: HashMap::new(), referenced_attachments: HashMap::new(),
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()), user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
running_command: None, running_command: None,
intended_agent: Some(user_query.intended_agent()), intended_agent: Some(user_query.intended_agent()),
}); });
true true
}
} }
api::message::Message::SystemQuery(query) => { api::message::Message::SystemQuery(query) => {
let Some(query_type) = &query.r#type else { let Some(query_type) = &query.r#type else {
@@ -4,7 +4,7 @@ use chrono::Utc;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::*; use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::api::{mark_internal_command_completion_assessment, ServerConversationToken};
use crate::ai::agent::conversation::{ use crate::ai::agent::conversation::{
AIAgentHarness, AIConversationId, ServerAIConversationMetadata, AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
}; };
@@ -2129,6 +2129,61 @@ fn test_create_then_edit_then_create_version_tracking() {
); );
} }
#[test]
fn test_internal_command_completion_assessment_restores_output_without_visible_input() {
let assessment_text =
"[Completed command: cargo test]\n[Final terminal output:\ntest result: ok\n]";
let mut hidden_assessment = api::Message {
id: "msg_assessment".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: assessment_text.to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let provider_history =
crate::ai::bedrock::request_translator::convert_proto_message(&hidden_assessment)
.expect("hidden assessment should remain in provider history");
assert_eq!(
provider_history.role,
crate::ai::provider::types::MessageRole::User
);
assert!(matches!(
provider_history.content,
crate::ai::provider::types::MessageContent::Text(text) if text == assessment_text
));
let task = api::Task {
id: "task1".to_string(),
messages: vec![
hidden_assessment,
api::Message {
id: "msg_output".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "The command completed successfully.".to_string(),
},
)),
..Default::default()
},
],
..Default::default()
};
let exchanges = task.into_exchanges();
assert_eq!(exchanges.len(), 1);
assert!(exchanges[0].input.is_empty());
assert_eq!(
exchanges[0].format_output_for_copy(None),
"The command completed successfully."
);
}
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce /// Verify that a `SystemQuery::HandoffRehydration` message does not produce
/// a displayed input when restoring a conversation. It must be treated as /// a displayed input when restoring a conversation. It must be treated as
/// hidden, so the exchange should have zero user-visible inputs. /// hidden, so the exchange should have zero user-visible inputs.
+22 -9
View File
@@ -16,16 +16,18 @@ use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::{ use crate::ai::agent::api::convert_conversation::{
convert_input_context, convert_tool_call_result_to_input, convert_input_context, convert_tool_call_result_to_input,
}; };
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::comment::CodeReview; use crate::ai::agent::comment::CodeReview;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList; use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections; use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput, runtime_activity, AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation,
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL, AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData,
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, CloneRepositoryURL, MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule, RunAgentsRequest, StartAgentExecutionMode, SubagentCall, SubagentType,
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus, SuggestedAgentModeWorkflow, SuggestedRule, Suggestions, SummarizationType, TodoOperation,
UserQueryMode, WebFetchStatus, WebSearchStatus,
}; };
use crate::ai::artifact_download::sanitized_basename; use crate::ai::artifact_download::sanitized_basename;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
@@ -272,10 +274,18 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?; .collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
match message { match message {
api::message::Message::AgentOutput(output) => Ok(MaybeAIAgentOutputMessage::Message( api::message::Message::AgentOutput(output) => {
AIAgentOutputMessage::text(MessageId::new(self.id), output.into()) let message = if let Some(activity) =
.with_citations(citations), runtime_activity::decode(&self.server_message_data)
)), {
AIAgentOutputMessage::runtime_activity(MessageId::new(self.id), activity)
} else {
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
};
Ok(MaybeAIAgentOutputMessage::Message(
message.with_citations(citations),
))
}
api::message::Message::AgentReasoning(reasoning) => { api::message::Message::AgentReasoning(reasoning) => {
let duration = reasoning let duration = reasoning
.finished_duration .finished_duration
@@ -948,6 +958,9 @@ pub fn user_inputs_from_messages(messages: &[api::Message]) -> Vec<AIAgentInput>
let Some(inner) = &m.message else { continue }; let Some(inner) = &m.message else { continue };
match inner { match inner {
api::message::Message::UserQuery(uq) => { api::message::Message::UserQuery(uq) => {
if is_internal_command_completion_assessment(m) {
continue;
}
let context = convert_input_context(uq.context.as_ref()); let context = convert_input_context(uq.context.as_ref());
let referenced_attachments = uq let referenced_attachments = uq
.referenced_attachments .referenced_attachments
+78 -3
View File
@@ -2,16 +2,19 @@ use std::path::PathBuf;
use ai::agent::action::AskUserQuestionType; use ai::agent::action::AskUserQuestionType;
use ai::skills::{SkillPathOrigin, SkillReference}; use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath; use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::{ use super::{
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage, convert_api_question, user_inputs_from_messages, ConversionParams,
MaybeAIAgentOutputMessage, ConvertAPIMessageToClientOutputMessage, MaybeAIAgentOutputMessage,
}; };
use crate::ai::agent::api::mark_internal_command_completion_assessment;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode, runtime_activity, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
LifecycleEventType, StartAgentExecutionMode,
}; };
fn start_agent_tool_call_message( fn start_agent_tool_call_message(
@@ -615,6 +618,36 @@ fn converts_local_start_agent_v2_with_harness_type() {
assert_eq!(lifecycle_subscription, None); assert_eq!(lifecycle_subscription, None);
} }
#[test]
fn internal_command_completion_assessment_is_not_restored_as_shared_user_input() {
let mut hidden_assessment = api::Message {
id: "hidden-assessment".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "[Completed command: cargo test]".to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let visible_query = api::Message {
id: "visible-query".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "What changed?".to_string(),
..Default::default()
})),
..Default::default()
};
let inputs = user_inputs_from_messages(&[hidden_assessment, visible_query]);
assert_eq!(inputs.len(), 1);
assert!(matches!(
&inputs[0],
AIAgentInput::UserQuery { query, .. } if query == "What changed?"
));
}
#[test] #[test]
fn transfer_control_tool_call_converts_to_action_message() { fn transfer_control_tool_call_converts_to_action_message() {
let task_id = TaskId::new("task".to_string()); let task_id = TaskId::new("task".to_string());
@@ -665,3 +698,45 @@ fn transfer_control_tool_call_converts_to_action_message() {
} }
} }
} }
#[test]
fn structured_runtime_activity_converts_to_display_only_output() {
let activity = RuntimeActivity {
id: "tool-1".to_owned(),
title: "List directories".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("payments\nrecords_v2".to_owned()),
};
let task_id = TaskId::new("task".to_owned());
let message = api::Message {
fetched_memories: Vec::new(),
id: "message".to_owned(),
task_id: "task".to_owned(),
server_message_data: runtime_activity::encode(&activity).expect("metadata"),
citations: Vec::new(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "fallback text".to_owned(),
},
)),
request_id: "request".to_owned(),
timestamp: None,
};
let converted = message
.to_client_output_message(ConversionParams {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion");
let MaybeAIAgentOutputMessage::Message(output) = converted else {
panic!("expected display output");
};
assert_eq!(
output.message,
AIAgentOutputMessageType::RuntimeActivity(activity)
);
}
+37
View File
@@ -348,6 +348,43 @@ fn convert_input_to_user_input(
} }
)) ))
} }
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command:
RunningCommand {
command,
block_id,
grid_contents: output,
cursor,
requested_command_id,
is_alt_screen_active,
},
..
} => Ok(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: prompt,
referenced_attachments: Default::default(),
mode: Some(UserQueryMode::Normal.into()),
intended_agent: api::AgentType::Primary.into(),
}),
running_command: Some(api::RunningShellCommand {
command,
snapshot: Some(api::LongRunningShellCommandSnapshot {
output,
cursor,
command_id: block_id.as_str().to_owned(),
is_alt_screen_active,
is_preempted: false,
}),
}),
run_shell_command_tool_call_id: requested_command_id
.map(|id| id.to_string())
.unwrap_or_default(),
},
),
),
AIAgentInput::ActionResult { result, .. } => result.try_into(), AIAgentInput::ActionResult { result, .. } => result.try_into(),
AIAgentInput::MessagesReceivedFromAgents { messages } => Ok( AIAgentInput::MessagesReceivedFromAgents { messages } => Ok(
api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents( api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents(
+82 -2
View File
@@ -1,11 +1,13 @@
use std::sync::Arc;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use galaxy_core::command::ExitCode; use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, ImageContext, AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput, ImageContext,
TransferShellCommandControlToUserResult, RunningCommand, TransferShellCommandControlToUserResult, UserQueryMode,
}; };
use crate::terminal::model::block::BlockId; use crate::terminal::model::block::BlockId;
@@ -132,6 +134,84 @@ fn git_context_deserializes_legacy_string_pull_request_number() {
assert_eq!(pull_request.number, 42); assert_eq!(pull_request.number, 42);
} }
#[test]
fn command_completion_assessment_converts_to_primary_cli_query() {
let block_id = BlockId::from("completed-block".to_string());
let converted = super::convert_input(vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Summarize whether the command succeeded.".to_string(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_string())]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: "cursor".to_string(),
requested_command_id: Some("run-call".to_string().into()),
is_alt_screen_active: true,
},
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = converted.r#type else {
panic!("expected user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected CLI agent query");
};
let user_query = query
.user_query
.as_ref()
.expect("expected assessment prompt");
assert_eq!(user_query.query, "Summarize whether the command succeeded.");
assert_eq!(user_query.intended_agent(), api::AgentType::Primary);
let command = query
.running_command
.as_ref()
.expect("expected completed command");
assert_eq!(command.command, "cargo test -p galaxy");
let snapshot = command.snapshot.as_ref().expect("expected final snapshot");
assert_eq!(snapshot.command_id, block_id.as_str());
assert_eq!(snapshot.output, "test result: ok");
assert_eq!(snapshot.cursor, "cursor");
assert!(snapshot.is_alt_screen_active);
assert_eq!(query.run_shell_command_tool_call_id, "run-call");
let active = super::convert_input(vec![AIAgentInput::UserQuery {
query: "Keep monitoring.".to_string(),
context: Arc::from([]),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id,
grid_contents: "still running".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
}),
intended_agent: None,
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = active.r#type else {
panic!("expected active user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected active CLI agent query");
};
assert_eq!(
query
.user_query
.as_ref()
.expect("expected active monitor prompt")
.intended_agent(),
api::AgentType::Cli
);
}
#[test] #[test]
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() { fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default(); let block_id = BlockId::default();
+33 -204
View File
@@ -1,217 +1,43 @@
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::StreamExt;
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use super::convert_to::convert_input; use super::RequestParams;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream};
use crate::ai::agent::redaction; use crate::ai::agent::redaction;
use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig;
use crate::server::server_api::AIApiError;
use crate::terminal::model::session::SessionType; use crate::terminal::model::session::SessionType;
pub async fn generate_multi_agent_output( fn remove_orchestration_tools_if_disabled(
provider_config: ProviderConfig, supported_tools: &mut Vec<api::ToolType>,
mut params: RequestParams, orchestration_enabled: bool,
cancellation_rx: futures::channel::oneshot::Receiver<()>, ) {
) -> Result<ResponseStream, ConvertToAPITypeError> { if orchestration_enabled {
let supported_tools_override = params.supported_tools_override.take(); return;
let supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(&params));
let supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(&params));
let mut logging_metadata = HashMap::new();
if let Some(metadata) = params.metadata {
logging_metadata.insert(
"is_autodetected_user_query".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::BoolValue(
metadata.is_autodetected_user_query,
)),
},
);
logging_metadata.insert(
"entrypoint".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::StringValue(
metadata.entrypoint.entrypoint(),
)),
},
);
logging_metadata.insert(
"is_auto_resume_after_error".to_owned(),
prost_types::Value {
kind: Some(prost_types::value::Kind::BoolValue(
metadata.is_auto_resume_after_error,
)),
},
);
} }
supported_tools.retain(|tool| {
!matches!(
tool,
api::ToolType::Subagent | api::ToolType::RunAgents | api::ToolType::StartAgentV2
)
});
}
pub(crate) fn prepare_direct_provider_params(
params: &mut RequestParams,
) -> (Vec<api::ToolType>, Vec<api::ToolType>) {
let supported_tools_override = params.supported_tools_override.take();
let mut supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(params));
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
let mut supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(params));
remove_orchestration_tools_if_disabled(
&mut supported_cli_agent_tools,
params.orchestration_enabled,
);
if params.should_redact_secrets { if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input); redaction::redact_inputs(&mut params.input);
} }
(supported_tools, supported_cli_agent_tools)
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
tasks: params.tasks,
}),
input: Some(convert_input(params.input)?),
settings: Some(api::request::Settings {
model_config: Some(api::request::settings::ModelConfig {
base: params.model.clone().into(),
cli_agent: params.cli_agent_model.clone().into(),
computer_use_agent: params.computer_use_model.clone().into(),
base_model_context_window_limit: params.context_window_limit.unwrap_or(0),
..Default::default()
}),
rules_enabled: params.is_memory_enabled,
warp_drive_context_enabled: params.warp_drive_context_enabled,
web_context_retrieval_enabled: true,
supports_parallel_tool_calls: true,
use_anthropic_text_editor_tools: false,
planning_enabled: params.planning_enabled,
supports_create_files: true,
supported_tools: supported_tools.into_iter().map(Into::into).collect(),
supports_long_running_commands: true,
should_preserve_file_content_in_history: true,
supports_todos_ui: true,
supports_linked_code_blocks: FeatureFlag::LinkedCodeBlocks.is_enabled(),
supports_started_child_task_message: true,
// Galaxy's direct providers only receive tools with local schemas and
// executors. Hosted-only suggestion/orchestration capability bits must
// remain false so models do not plan around unavailable Warp services.
supports_suggest_prompt: false,
supports_read_image_files: FeatureFlag::ReadImageFiles.is_enabled(),
supports_reasoning_message: true,
api_keys: params.api_keys,
autonomy_level: params.autonomy_level.into(),
isolation_level: params.isolation_level.into(),
web_search_enabled: params.web_search_enabled,
supported_cli_agent_tools: supported_cli_agent_tools
.into_iter()
.map(Into::into)
.collect(),
supports_v4a_file_diffs: FeatureFlag::V4AFileDiffs.is_enabled(),
supports_summarization_via_message_replacement:
FeatureFlag::SummarizationViaMessageReplacement.is_enabled(),
supports_bundled_skills: FeatureFlag::BundledSkills.is_enabled(),
supports_research_agent: params.research_agent_enabled,
supports_orchestration_v2: false,
supports_background_computer_use: FeatureFlag::BackgroundComputerUse.is_enabled()
&& computer_use::background_supported(),
custom_model_providers: params.custom_model_providers,
custom_model_routers: params.custom_model_routers,
}),
metadata: Some(api::request::Metadata {
logging: logging_metadata,
conversation_id: params
.conversation_token
.as_ref()
.map(|token| token.as_str().to_string())
.unwrap_or_default(),
ambient_agent_task_id: params
.ambient_agent_task_id
.map(|id| id.to_string())
.unwrap_or_default(),
forked_from_conversation_id: if params.conversation_token.is_none() {
// We only include this param on our initial request to the server
// (when the forked conversation has not been assigned a new id yet).
params
.forked_from_conversation_token
.map(|token| token.as_str().to_string())
.unwrap_or_default()
} else {
String::new()
},
parent_agent_id: params.parent_agent_id.unwrap_or_default(),
agent_name: params.agent_name.unwrap_or_default(),
}),
existing_suggestions: params
.existing_suggestions
.map(|suggestions| suggestions.into()),
mcp_context: params.mcp_context.map(Into::into),
};
match provider_config {
ProviderConfig::OpenAI(config) => {
let translator_request = openai_translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
message_history: params.bedrock_message_history.clone(),
tool_result_archive: params.bedrock_tool_result_archive.clone(),
progressive_summary: params.bedrock_progressive_summary.clone(),
messages_sent: params.bedrock_messages_sent.clone(),
global_rules: params.global_rules.clone(),
};
match openai_translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[openai] Translator error: {e}");
let err = Arc::new(
crate::server::server_api::AIApiError::Stream {
stream_type: "openai_chat_completions",
source: anyhow::anyhow!("{e}"),
}
.into_quota_limit_if_provider_budget_exhausted(),
);
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
ProviderConfig::Bedrock(config) => {
let translator_request = crate::ai::bedrock::translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
global_rules: params.global_rules.clone(),
};
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
Ok(Box::pin(output_stream))
}
Err(e) => {
log::error!("[bedrock] Translator error: {e}");
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
}
ProviderConfig::None => {
// No provider configured — do not fall back to Warp's cloud API.
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "none",
source: anyhow::anyhow!(
"No AI provider configured. Enable Bedrock or OpenAI/LiteLLM in settings."
),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
Ok(Box::pin(rx))
}
}
} }
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> { fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
@@ -222,7 +48,6 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
api::ToolType::ReadMcpResource, api::ToolType::ReadMcpResource,
api::ToolType::CallMcpTool, api::ToolType::CallMcpTool,
api::ToolType::RunShellCommand, api::ToolType::RunShellCommand,
api::ToolType::Subagent,
api::ToolType::WriteToLongRunningShellCommand, api::ToolType::WriteToLongRunningShellCommand,
api::ToolType::ReadShellCommandOutput, api::ToolType::ReadShellCommandOutput,
api::ToolType::ReadDocuments, api::ToolType::ReadDocuments,
@@ -230,6 +55,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
api::ToolType::EditDocuments, api::ToolType::EditDocuments,
]; ];
if params.orchestration_enabled {
supported_tools.push(api::ToolType::Subagent);
}
if FeatureFlag::ConversationsAsContext.is_enabled() { if FeatureFlag::ConversationsAsContext.is_enabled() {
supported_tools.push(api::ToolType::FetchConversation); supported_tools.push(api::ToolType::FetchConversation);
} }
+52 -5
View File
@@ -2,7 +2,9 @@ use galaxy_core::features::FeatureFlag;
use galaxy_core::HostId; use galaxy_core::HostId;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use super::{get_supported_cli_agent_tools, get_supported_tools}; use super::{
get_supported_cli_agent_tools, get_supported_tools, remove_orchestration_tools_if_disabled,
};
use crate::ai::agent::api::RequestParams; use crate::ai::agent::api::RequestParams;
use crate::ai::blocklist::SessionContext; use crate::ai::blocklist::SessionContext;
use crate::ai::llms::LLMId; use crate::ai::llms::LLMId;
@@ -14,6 +16,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
RequestParams { RequestParams {
terminal_view_id: None, terminal_view_id: None,
input: vec![], input: vec![],
tool_results: vec![],
conversation_token: None, conversation_token: None,
forked_from_conversation_token: None, forked_from_conversation_token: None,
ambient_agent_task_id: None, ambient_agent_task_id: None,
@@ -45,10 +48,10 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
root_task_id: None, root_task_id: None,
parent_agent_id: None, parent_agent_id: None,
agent_name: None, agent_name: None,
bedrock_message_history: Vec::new(), message_history: Vec::new(),
bedrock_progressive_summary: None, progressive_summary: None,
bedrock_tool_result_archive: Vec::new(), tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
global_rules: Vec::new(), global_rules: Vec::new(),
} }
} }
@@ -76,6 +79,50 @@ fn supported_tools_expose_local_subagents_without_hosted_orchestration_tools() {
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2)); assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
} }
#[test]
fn supported_tools_include_plan_document_capabilities() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(supported_tools.contains(&api::ToolType::ReadDocuments));
assert!(supported_tools.contains(&api::ToolType::CreateDocuments));
assert!(supported_tools.contains(&api::ToolType::EditDocuments));
}
#[test]
fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::Subagent));
}
#[test]
fn supported_tool_override_cannot_restore_leaf_orchestration_tools() {
let mut supported_tools = vec![
api::ToolType::Grep,
api::ToolType::Subagent,
api::ToolType::RunAgents,
api::ToolType::StartAgentV2,
];
remove_orchestration_tools_if_disabled(&mut supported_tools, false);
assert_eq!(supported_tools, vec![api::ToolType::Grep]);
}
#[test]
fn enabled_orchestration_preserves_supported_tool_override() {
let mut supported_tools = vec![api::ToolType::Grep, api::ToolType::Subagent];
remove_orchestration_tools_if_disabled(&mut supported_tools, true);
assert_eq!(
supported_tools,
vec![api::ToolType::Grep, api::ToolType::Subagent]
);
}
#[test] #[test]
fn supported_tools_omit_hosted_only_capabilities() { fn supported_tools_omit_hosted_only_capabilities() {
let params = request_params_with_ask_user_question_enabled(false); let params = request_params_with_ask_user_question_enabled(false);
+227 -11
View File
@@ -229,6 +229,9 @@ pub struct AIConversation {
/// Runtime responsible for executing this conversation. /// Runtime responsible for executing this conversation.
agent_backend: AgentBackend, agent_backend: AgentBackend,
/// Opaque, versioned snapshot of the active direct-provider run.
active_provider_run_json: Option<String>,
/// The server-generated unique "token" for this conversation. /// The server-generated unique "token" for this conversation.
/// ///
/// This must be roundtripped to the server when sending follow-ups within a given conversation. /// This must be roundtripped to the server when sending follow-ups within a given conversation.
@@ -380,6 +383,7 @@ impl AIConversation {
has_opened_code_review: false, has_opened_code_review: false,
conversation_usage_metadata: ConversationUsageMetadata::default(), conversation_usage_metadata: ConversationUsageMetadata::default(),
agent_backend, agent_backend,
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
task_id: None, task_id: None,
forked_from_server_conversation_token: None, forked_from_server_conversation_token: None,
@@ -539,6 +543,7 @@ impl AIConversation {
let ( let (
agent_backend, agent_backend,
active_provider_run_json,
server_conversation_token, server_conversation_token,
forked_from_server_conversation_token, forked_from_server_conversation_token,
conversation_usage_metadata, conversation_usage_metadata,
@@ -589,6 +594,7 @@ impl AIConversation {
}; };
( (
data.agent_backend, data.agent_backend,
data.active_provider_run_json,
server_conversation_token, server_conversation_token,
forked_from_server_conversation_token, forked_from_server_conversation_token,
conversation_usage_metadata, conversation_usage_metadata,
@@ -611,6 +617,7 @@ impl AIConversation {
AgentBackend::default(), AgentBackend::default(),
None, None,
None, None,
None,
ConversationUsageMetadata::default(), ConversationUsageMetadata::default(),
HashSet::new(), HashSet::new(),
Vec::new(), Vec::new(),
@@ -663,6 +670,7 @@ impl AIConversation {
has_opened_code_review: false, has_opened_code_review: false,
conversation_usage_metadata, conversation_usage_metadata,
agent_backend, agent_backend,
active_provider_run_json,
server_conversation_token, server_conversation_token,
task_id: run_id.as_deref().and_then(|id| id.parse().ok()), task_id: run_id.as_deref().and_then(|id| id.parse().ok()),
forked_from_server_conversation_token, forked_from_server_conversation_token,
@@ -705,6 +713,35 @@ impl AIConversation {
&self.agent_backend &self.agent_backend
} }
pub(crate) fn active_provider_run_json(&self) -> Option<&str> {
self.active_provider_run_json.as_deref()
}
pub(crate) fn set_active_provider_run_json(&mut self, snapshot: Option<String>) {
self.active_provider_run_json = snapshot;
}
/// Updates the backend of a conversation that has not produced agent output.
///
/// Provider failures without output are safe to retry through a newly enabled runtime. Once
/// any exchange has produced output, the backend remains stable so provider-native and
/// ACP-owned histories are never mixed.
pub(crate) fn set_agent_backend_if_no_output(&mut self, agent_backend: AgentBackend) -> bool {
let can_change_backend = self.all_exchanges().iter().all(|exchange| {
matches!(
&exchange.output_status,
AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error { output: None, .. }
}
)
});
if !can_change_backend && self.agent_backend != agent_backend {
return false;
}
self.agent_backend = agent_backend;
true
}
/// Records a resumable ACP session ID. /// Records a resumable ACP session ID.
/// ///
/// Returns `false` when called for a native provider conversation. /// Returns `false` when called for a native provider conversation.
@@ -1893,7 +1930,8 @@ impl AIConversation {
) -> String { ) -> String {
let mut result = Vec::new(); let mut result = Vec::new();
for exchange in self.all_exchanges() { for exchange in self.all_exchanges() {
let formatted_exchange = exchange.format_for_copy(action_model); let formatted_exchange =
exchange.format_for_copy_for_conversation(action_model, Some(self.id()));
if !formatted_exchange.is_empty() { if !formatted_exchange.is_empty() {
result.push(formatted_exchange); result.push(formatted_exchange);
} }
@@ -1971,21 +2009,25 @@ impl AIConversation {
.sum() .sum()
} }
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool { pub fn action(&self, action_id: &AIAgentActionId) -> Option<AIAgentAction> {
self.task_store.tasks().any(|task| { self.task_store.tasks().find_map(|task| {
task.exchanges() task.exchanges().find_map(|exchange| {
.any(|exchange| { let output = exchange.output_status.output()?;
let Some(output) = exchange.output_status.output() output.get().messages.iter().find_map(|step| match step {
else { AIAgentOutputMessage {
return false; message: AIAgentOutputMessageType::Action(action),
}; ..
output.get().messages.iter().any(|step| { } if &action.id == action_id => Some(action.clone()),
matches!(step, AIAgentOutputMessage{ message: AIAgentOutputMessageType::Action(AIAgentAction { id, .. }), .. } if id == action_id) AIAgentOutputMessage { .. } => None,
}) })
}) })
}) })
} }
pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool {
self.action(action_id).is_some()
}
/// Returns the exchange ID that contains the given action ID, if any. /// Returns the exchange ID that contains the given action ID, if any.
pub fn exchange_id_for_action(&self, action_id: &AIAgentActionId) -> Option<AIAgentExchangeId> { pub fn exchange_id_for_action(&self, action_id: &AIAgentActionId) -> Option<AIAgentExchangeId> {
for task in self.task_store.tasks() { for task in self.task_store.tasks() {
@@ -2088,6 +2130,81 @@ impl AIConversation {
Ok(()) Ok(())
} }
/// Reopens an exact restored exchange for continued provider projection.
///
/// This only restores the process-local stream association; it never adds input or provider
/// history, so the persisted provider run remains the sole continuation source of truth.
pub(crate) fn provider_projection_target(
&self,
response_stream_id: &ResponseStreamId,
) -> Option<(TaskId, AIAgentExchangeId)> {
let mut exchanges = self
.added_exchanges_by_response
.get(response_stream_id)?
.iter();
let target = exchanges.next()?;
exchanges
.next()
.is_none()
.then(|| (target.task_id.clone(), target.exchange_id))
}
pub(crate) fn rebind_provider_projection(
&mut self,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> {
let Some(task) = self.task_store.get(task_id) else {
return Err(UpdateConversationError::TaskNotFound);
};
if !task.exchanges().any(|exchange| exchange.id == exchange_id) {
return if self.exchange_with_id(exchange_id).is_some() {
Err(UpdateConversationError::ExchangeTaskMismatch)
} else {
Err(UpdateConversationError::ExchangeNotFound)
};
}
if self
.added_exchanges_by_response
.contains_key(&response_stream_id)
{
return Err(UpdateConversationError::ResponseStreamAlreadyBound);
}
let exchange = self.get_exchange_to_update(exchange_id)?;
let previous_status = std::mem::replace(
&mut exchange.output_status,
AIAgentOutputStatus::Streaming { output: None },
);
let output = match previous_status {
AIAgentOutputStatus::Streaming { output } => output,
AIAgentOutputStatus::Finished { finished_output } => match finished_output {
FinishedAIAgentOutput::Cancelled { output, .. }
| FinishedAIAgentOutput::Error { output, .. } => output,
FinishedAIAgentOutput::Success { output } => Some(output),
},
};
exchange.output_status = AIAgentOutputStatus::Streaming { output };
exchange.finish_time = None;
self.added_exchanges_by_response.insert(
response_stream_id,
Vec1::new(AddedExchange {
task_id: task_id.clone(),
exchange_id,
}),
);
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id,
conversation_id: self.id,
is_hidden: self.hidden_exchanges.contains(&exchange_id),
});
Ok(())
}
pub fn append_reassigned_exchange( pub fn append_reassigned_exchange(
&mut self, &mut self,
response_stream_id: &ResponseStreamId, response_stream_id: &ResponseStreamId,
@@ -2227,6 +2344,100 @@ impl AIConversation {
Ok(()) Ok(())
} }
pub fn apply_domain_tool_proposal(
&mut self,
stream_id: &ResponseStreamId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<(), UpdateConversationError> {
if self.contains_action(&action.id) {
return Ok(());
}
let exchange_id = self.ensure_response_exchange_for_task(
stream_id,
&action.task_id,
terminal_surface_id,
ctx,
)?;
let message_id = MessageId::new(action.id.to_string());
let exchange = self.get_exchange_to_update(exchange_id)?;
match &exchange.output_status {
AIAgentOutputStatus::Streaming {
output: Some(output),
} => output
.get_mut()
.messages
.push(AIAgentOutputMessage::action(message_id, action)),
AIAgentOutputStatus::Streaming { output: None } => {
return Err(UpdateConversationError::OutputNeverInitialized);
}
AIAgentOutputStatus::Finished { .. } => {
return Err(UpdateConversationError::OutputAlreadyFinished);
}
}
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_surface_id,
conversation_id: self.id,
is_hidden: self.hidden_exchanges.contains(&exchange_id),
});
Ok(())
}
fn ensure_response_exchange_for_task(
&mut self,
stream_id: &ResponseStreamId,
task_id: &TaskId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) -> Result<AIAgentExchangeId, UpdateConversationError> {
let added_exchanges = self
.added_exchanges_by_response
.get(stream_id)
.ok_or(UpdateConversationError::NoPendingRequest)?;
if let Some(exchange_id) = added_exchanges
.iter()
.find_map(|added| (added.task_id == *task_id).then_some(added.exchange_id))
{
return Ok(exchange_id);
}
// Direct-provider command monitoring can switch tasks within one response stream. A
// tool-first monitor turn needs an exchange before any message event can create it.
let source_exchange = added_exchanges.last().clone();
let existing_exchange = self
.task_store
.get(&source_exchange.task_id)
.ok_or(UpdateConversationError::TaskNotFound)?
.exchange(source_exchange.exchange_id)
.cloned()
.ok_or(UpdateConversationError::ExchangeNotFound)?;
let mut task = self
.task_store
.remove(task_id)
.ok_or(UpdateConversationError::TaskNotFound)?;
let exchange_id = task.append_new_exchange(&existing_exchange);
self.task_store.insert(task);
self.added_exchanges_by_response
.get_mut(stream_id)
.ok_or(UpdateConversationError::NoPendingRequest)?
.push(AddedExchange {
task_id: task_id.clone(),
exchange_id,
});
let is_hidden = self.hidden_exchanges.contains(&exchange_id);
ctx.emit(BlocklistAIHistoryEvent::AppendedExchange {
response_stream_id: Some(stream_id.clone()),
exchange_id,
task_id: task_id.clone(),
terminal_surface_id,
conversation_id: self.id,
is_hidden,
});
Ok(exchange_id)
}
pub fn update_cost_and_usage_for_request( pub fn update_cost_and_usage_for_request(
&mut self, &mut self,
request_cost: Option<RequestCost>, request_cost: Option<RequestCost>,
@@ -3824,6 +4035,7 @@ impl AIConversation {
.collect(), .collect(),
conversation_data: AgentConversationData { conversation_data: AgentConversationData {
agent_backend: self.agent_backend.clone(), agent_backend: self.agent_backend.clone(),
active_provider_run_json: self.active_provider_run_json.clone(),
server_conversation_token: self server_conversation_token: self
.server_conversation_token .server_conversation_token
.clone() .clone()
@@ -4701,6 +4913,10 @@ fn cleanup_conversation_search_temp_dir(
pub enum UpdateConversationError { pub enum UpdateConversationError {
#[error("Exchange not found.")] #[error("Exchange not found.")]
ExchangeNotFound, ExchangeNotFound,
#[error("Exchange does not belong to the persisted task.")]
ExchangeTaskMismatch,
#[error("Response stream is already bound to an exchange.")]
ResponseStreamAlreadyBound,
#[error("Could not update task: {0:?}")] #[error("Could not update task: {0:?}")]
UpdateTask(#[from] UpdateTaskError), UpdateTask(#[from] UpdateTaskError),
#[error("Could not update upgrade optimistic task for server task: {0:?}")] #[error("Could not update upgrade optimistic task for server task: {0:?}")]
+25
View File
@@ -106,6 +106,15 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
.unwrap() .unwrap()
} }
#[test]
fn agent_backend_does_not_change_after_successful_output() {
let mut conversation = restored_conversation_with_queries(&["Review this repository"]);
assert!(!conversation
.set_agent_backend_if_no_output(AgentBackend::Acp(AcpConversationData::default())));
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
}
#[test] #[test]
fn latest_user_query_returns_latest_non_empty_user_query() { fn latest_user_query_returns_latest_non_empty_user_query() {
let conversation = let conversation =
@@ -184,6 +193,19 @@ fn restored_conversation_defaults_autoexecute_override_when_not_persisted() {
); );
} }
#[test]
fn restored_conversation_retains_active_provider_run_json() {
let snapshot = r#"{"version":1,"run":{"state":"awaiting_model"}}"#;
let conversation_data = AgentConversationData {
active_provider_run_json: Some(snapshot.to_string()),
..Default::default()
};
let conversation = restored_conversation(Some(conversation_data));
assert_eq!(conversation.active_provider_run_json(), Some(snapshot));
}
#[test] #[test]
fn restored_conversation_uses_persisted_last_event_sequence() { fn restored_conversation_uses_persisted_last_event_sequence() {
let conversation_data: AgentConversationData = let conversation_data: AgentConversationData =
@@ -219,6 +241,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
false, false,
false, false,
AgentBackend::Acp(AcpConversationData { AgentBackend::Acp(AcpConversationData {
provider_id: "provider-1".to_string(),
agent_id: "codex-acp".to_string(), agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(), launch_fingerprint: "launch-123".to_string(),
session_id: None, session_id: None,
@@ -229,6 +252,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
assert_eq!( assert_eq!(
acp_conversation.agent_backend(), acp_conversation.agent_backend(),
&AgentBackend::Acp(AcpConversationData { &AgentBackend::Acp(AcpConversationData {
provider_id: "provider-1".to_string(),
agent_id: "codex-acp".to_string(), agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(), launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()), session_id: Some("session-123".to_string()),
@@ -247,6 +271,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
#[test] #[test]
fn restored_conversation_uses_persisted_acp_backend() { fn restored_conversation_uses_persisted_acp_backend() {
let backend = AgentBackend::Acp(AcpConversationData { let backend = AgentBackend::Acp(AcpConversationData {
provider_id: "provider-1".to_string(),
agent_id: "codex-acp".to_string(), agent_id: "codex-acp".to_string(),
launch_fingerprint: "launch-123".to_string(), launch_fingerprint: "launch-123".to_string(),
session_id: Some("session-123".to_string()), session_id: Some("session-123".to_string()),
+80 -6
View File
@@ -7,6 +7,7 @@ pub(crate) mod comment;
pub(crate) mod icons; pub(crate) mod icons;
pub(crate) mod linearization; pub(crate) mod linearization;
pub(crate) mod redaction; pub(crate) mod redaction;
pub(crate) mod runtime_activity;
pub(crate) mod task; pub(crate) mod task;
mod task_store; mod task_store;
pub(super) mod telemetry; pub(super) mod telemetry;
@@ -27,6 +28,7 @@ use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta}; use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment; use comment::ReviewComment;
use derivative::Derivative; use derivative::Derivative;
use galaxy_agent_core::RuntimeActivity;
use galaxy_core::channel::ChannelState; use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline}; use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
@@ -579,6 +581,14 @@ impl AIAgentOutput {
pub fn format_for_copy( pub fn format_for_copy(
&self, &self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>, action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_for_copy_for_conversation(action_model, None)
}
pub fn format_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String { ) -> String {
let mut result = Vec::new(); let mut result = Vec::new();
let mut last_was_action = false; let mut last_was_action = false;
@@ -610,8 +620,12 @@ impl AIAgentOutput {
} }
AIAgentOutputMessageType::Action(action) => { AIAgentOutputMessageType::Action(action) => {
// Include action results from the action model if available // Include action results from the action model if available
if let Some(action_model) = action_model { if let (Some(action_model), Some(conversation_id)) =
if let Some(action_result) = action_model.get_action_result(&action.id) { (action_model, conversation_id)
{
if let Some(action_result) =
action_model.get_action_result(conversation_id, &action.id)
{
result.push(format!("{}", MarkdownActionResult(&action_result.result))); result.push(format!("{}", MarkdownActionResult(&action_result.result)));
// Add an extra newline after tool call results for readability // Add an extra newline after tool call results for readability
result.push(String::new()); result.push(String::new());
@@ -619,6 +633,13 @@ impl AIAgentOutput {
} }
} }
} }
AIAgentOutputMessageType::RuntimeActivity(activity) => {
result.push(activity.title.clone());
if let Some(output) = &activity.output {
result.push(output.clone());
}
last_was_action = true;
}
AIAgentOutputMessageType::TodoOperation(operation) => { AIAgentOutputMessageType::TodoOperation(operation) => {
result.push(format!("{operation}")); result.push(format!("{operation}"));
last_was_action = false; last_was_action = false;
@@ -1213,6 +1234,9 @@ impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
write!(f, "\n_Command cancelled_") write!(f, "\n_Command cancelled_")
} }
RequestCommandOutputResult::ExecutionError { command, message } => {
write!(f, "\n_Command `{command}` was not executed: {message}_")
}
RequestCommandOutputResult::Denylisted { command } => { RequestCommandOutputResult::Denylisted { command } => {
write!( write!(
f, f,
@@ -1805,6 +1829,10 @@ pub enum AIAgentOutputMessageType {
token_count: Option<u32>, token_count: Option<u32>,
}, },
Subagent(SubagentCall), Subagent(SubagentCall),
/// Display-only activity executed and owned by an external agent runtime.
/// Unlike [`AIAgentOutputMessageType::Action`], Galaxy must never dispatch
/// this activity through its action executor.
RuntimeActivity(RuntimeActivity),
Action(AIAgentAction), Action(AIAgentAction),
TodoOperation(TodoOperation), TodoOperation(TodoOperation),
WebSearch(WebSearchStatus), WebSearch(WebSearchStatus),
@@ -1972,6 +2000,12 @@ impl Display for AIAgentOutputMessage {
} }
} }
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?, AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
AIAgentOutputMessageType::RuntimeActivity(activity) => {
write!(f, "Runtime activity: {}", activity.title)?;
if let Some(output) = &activity.output {
write!(f, "\n{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?, AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?, AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
AIAgentOutputMessageType::WebSearch(status) => match status { AIAgentOutputMessageType::WebSearch(status) => match status {
@@ -2044,6 +2078,14 @@ impl AIAgentOutputMessage {
} }
} }
pub fn runtime_activity(id: MessageId, activity: RuntimeActivity) -> Self {
Self {
id,
message: AIAgentOutputMessageType::RuntimeActivity(activity),
citations: vec![],
}
}
pub fn text(id: MessageId, text: AIAgentText) -> Self { pub fn text(id: MessageId, text: AIAgentText) -> Self {
Self { Self {
id, id,
@@ -2678,6 +2720,13 @@ pub enum AIAgentInput {
intended_agent: Option<AgentType>, intended_agent: Option<AgentType>,
}, },
/// A hidden system turn that asks for the final assessment of a completed command.
CommandCompletionAssessment {
prompt: String,
context: Arc<[AIAgentContext]>,
completed_command: RunningCommand,
},
AutoCodeDiffQuery { AutoCodeDiffQuery {
query: String, query: String,
context: Arc<[AIAgentContext]>, context: Arc<[AIAgentContext]>,
@@ -2842,6 +2891,9 @@ impl Display for AIAgentInput {
Self::UserQuery { .. } => { Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default()) write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
} }
Self::CommandCompletionAssessment { .. } => {
write!(f, "CommandCompletionAssessment")
}
Self::AutoCodeDiffQuery { query, .. } => { Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}") write!(f, "AutoCodeDiffQuery: {query}")
} }
@@ -2930,7 +2982,8 @@ impl AIAgentInput {
suggestion: PassiveSuggestionResultType::Prompt { prompt }, suggestion: PassiveSuggestionResultType::Prompt { prompt },
.. ..
} => Some(prompt.clone()), } => Some(prompt.clone()),
Self::AutoCodeDiffQuery { .. } Self::CommandCompletionAssessment { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ActionResult { .. } | Self::ActionResult { .. }
| Self::TriggerPassiveSuggestion { .. } | Self::TriggerPassiveSuggestion { .. }
| Self::ResumeConversation { .. } | Self::ResumeConversation { .. }
@@ -3021,6 +3074,7 @@ impl AIAgentInput {
pub fn context(&self) -> Option<&[AIAgentContext]> { pub fn context(&self) -> Option<&[AIAgentContext]> {
match self { match self {
Self::UserQuery { context, .. } Self::UserQuery { context, .. }
| Self::CommandCompletionAssessment { context, .. }
| Self::ActionResult { context, .. } | Self::ActionResult { context, .. }
| Self::AutoCodeDiffQuery { context, .. } | Self::AutoCodeDiffQuery { context, .. }
| Self::ResumeConversation { context, .. } | Self::ResumeConversation { context, .. }
@@ -3054,7 +3108,8 @@ impl AIAgentInput {
Some(res) Some(res)
} }
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()), Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
Self::ActionResult { .. } Self::CommandCompletionAssessment { .. }
| Self::ActionResult { .. }
| Self::AutoCodeDiffQuery { .. } | Self::AutoCodeDiffQuery { .. }
| Self::ResumeConversation { .. } | Self::ResumeConversation { .. }
| Self::InitProjectRules { .. } | Self::InitProjectRules { .. }
@@ -3185,9 +3240,19 @@ impl AIAgentExchange {
pub fn format_output_for_copy( pub fn format_output_for_copy(
&self, &self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>, action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_output_for_copy_for_conversation(action_model, None)
}
pub fn format_output_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String { ) -> String {
match self.output_status.output() { match self.output_status.output() {
Some(output) => output.get().format_for_copy(action_model), Some(output) => output
.get()
.format_for_copy_for_conversation(action_model, conversation_id),
None => String::new(), None => String::new(),
} }
} }
@@ -3198,9 +3263,18 @@ impl AIAgentExchange {
pub fn format_for_copy( pub fn format_for_copy(
&self, &self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>, action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
self.format_for_copy_for_conversation(action_model, None)
}
pub fn format_for_copy_for_conversation(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
conversation_id: Option<conversation::AIConversationId>,
) -> String { ) -> String {
let input_text = self.format_input_for_copy(); let input_text = self.format_input_for_copy();
let output_text = self.format_output_for_copy(action_model); let output_text =
self.format_output_for_copy_for_conversation(action_model, conversation_id);
let has_user_input = !input_text.is_empty(); let has_user_input = !input_text.is_empty();
let has_agent_output = !output_text.is_empty(); let has_agent_output = !output_text.is_empty();
+52 -2
View File
@@ -1,17 +1,22 @@
use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow; use anyhow::anyhow;
use chrono::Local;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_multi_agent_api::{FileContent, FileContentLineRange}; use warp_multi_agent_api::{FileContent, FileContentLineRange};
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentOutputStatus, AIAgentText,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage, AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
RenderableAIError, TransientNetworkErrorKind, RenderableAIError, RunningCommand, TransientNetworkErrorKind,
}; };
use crate::ai::llms::LLMId;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType; use crate::terminal::shell::ShellType;
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> { fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
@@ -21,6 +26,51 @@ fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
}) })
} }
#[test]
fn command_completion_assessment_stays_hidden_from_user_transcript() {
let context: Arc<[AIAgentContext]> =
Arc::from([AIAgentContext::SelectedText("relevant context".to_string())]);
let input = AIAgentInput::CommandCompletionAssessment {
prompt: "Report the final result.".to_string(),
context: context.clone(),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: BlockId::from("completed-command".to_string()),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
};
assert_eq!(input.display_query(), None);
assert!(!input.is_user_query());
assert!(!input.is_passive_request());
assert_eq!(input.context(), Some(context.as_ref()));
assert_eq!(input.attachments(), None);
let now = Local::now();
let exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![input],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: HashSet::new(),
start_time: now,
finish_time: None,
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-model"),
cli_agent_model_id: LLMId::from("test-model"),
computer_use_model_id: LLMId::from("test-model"),
response_initiator: None,
};
assert_eq!(exchange.format_input_for_copy(), "");
assert_eq!(exchange.format_for_copy(None), "");
assert!(!exchange.has_user_query());
}
#[test] #[test]
fn formatted_text_wrapper_shares_arc_across_calls() { fn formatted_text_wrapper_shares_arc_across_calls() {
let text = FormattedText::new([FormattedTextLine::Line(vec![ let text = FormattedText::new([FormattedTextLine::Line(vec![
+11
View File
@@ -47,6 +47,17 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
redact_secrets(&mut running_command.cursor); redact_secrets(&mut running_command.cursor);
} }
} }
AIAgentInput::CommandCompletionAssessment {
prompt,
context,
completed_command,
} => {
redact_secrets(prompt);
redact_context(Arc::make_mut(context));
redact_secrets(&mut completed_command.command);
redact_secrets(&mut completed_command.grid_contents);
redact_secrets(&mut completed_command.cursor);
}
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => { AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
redact_secrets(query); redact_secrets(query);
redact_context(Arc::make_mut(context)); redact_context(Arc::make_mut(context));
+12
View File
@@ -0,0 +1,12 @@
use galaxy_agent_core::RuntimeActivity;
const SERVER_MESSAGE_DATA_PREFIX: &str = "galaxy:runtime-activity:v1:";
pub(crate) fn encode(activity: &RuntimeActivity) -> Result<String, serde_json::Error> {
serde_json::to_string(activity).map(|json| format!("{SERVER_MESSAGE_DATA_PREFIX}{json}"))
}
pub(crate) fn decode(server_message_data: &str) -> Option<RuntimeActivity> {
let json = server_message_data.strip_prefix(SERVER_MESSAGE_DATA_PREFIX)?;
serde_json::from_str(json).ok()
}
+54 -1
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
@@ -360,6 +360,59 @@ impl TaskStore {
append_refs_for_task(tasks, &mut refs, root_task); append_refs_for_task(tasks, &mut refs, root_task);
} }
let indexed_task_ids = refs
.iter()
.map(|exchange_ref| exchange_ref.task_id.clone())
.collect::<HashSet<_>>();
let mut direct_cli_tasks = tasks
.values()
.filter(|task| {
!indexed_task_ids.contains(task.id())
&& task.parent_id().as_ref() == Some(root_task_id)
&& task.is_cli_subagent()
&& task
.subagent_params()
.is_some_and(|params| params.tool_call_id.is_empty())
&& task.exchanges().next().is_some()
})
.collect::<Vec<_>>();
direct_cli_tasks.sort_by(|left, right| {
left.exchanges()
.next()
.map(|exchange| exchange.start_time)
.cmp(&right.exchanges().next().map(|exchange| exchange.start_time))
.then_with(|| left.id().to_string().cmp(&right.id().to_string()))
});
// Direct providers synthesize CLI monitor tasks without a parent Subagent message.
// Place each task as one chronological block so its exchanges remain reachable without
// changing the DFS order of server-linked subtasks.
for task in direct_cli_tasks {
let first_start_time = task
.exchanges()
.next()
.expect("direct CLI task was filtered to contain an exchange")
.start_time;
let insertion_index = refs
.iter()
.position(|exchange_ref| {
tasks
.get(&exchange_ref.task_id)
.and_then(|task| task.exchanges().nth(exchange_ref.exchange_index))
.is_some_and(|exchange| exchange.start_time > first_start_time)
})
.unwrap_or(refs.len());
let task_id = task.id().clone();
let task_refs = task
.exchanges()
.enumerate()
.map(|(exchange_index, _)| ExchangeRef {
task_id: task_id.clone(),
exchange_index,
})
.collect::<Vec<_>>();
refs.splice(insertion_index..insertion_index, task_refs);
}
refs refs
} }
} }
+40
View File
@@ -146,6 +146,46 @@ fn test_insert_subtask() {
assert!(store.contains(&subtask_id)); assert!(store.contains(&subtask_id));
} }
#[test]
fn test_unlinked_direct_cli_task_is_linearized_chronologically() {
let base_time = Local::now();
let mut root_task = Task::new_optimistic_root();
let root_task_id = root_task.id().clone();
let mut before_cli = create_test_exchange();
before_cli.start_time = base_time;
let before_cli_id = before_cli.id;
root_task.append_exchange(before_cli);
let mut after_cli = create_test_exchange();
after_cli.start_time = base_time + chrono::Duration::seconds(2);
let after_cli_id = after_cli.id;
root_task.append_exchange(after_cli);
let mut cli_task =
Task::new_optimistic_cli_agent_subtask(BlockId::new(), Some(root_task_id.to_string()));
let mut cli_exchange = create_test_exchange();
cli_exchange.start_time = base_time + chrono::Duration::seconds(1);
let cli_exchange_id = cli_exchange.id;
cli_task.append_exchange(cli_exchange);
let mut store = TaskStore::with_root_task(root_task);
store.insert(cli_task);
let exchange_ids = store
.all_exchanges()
.map(|exchange| exchange.id)
.collect::<Vec<_>>();
assert_eq!(
exchange_ids,
vec![before_cli_id, cli_exchange_id, after_cli_id]
);
assert_eq!(
store.latest_exchange().map(|exchange| exchange.id),
Some(after_cli_id)
);
}
#[test] #[test]
fn test_remove_task() { fn test_remove_task() {
let task = create_test_task_with_exchanges(3); let task = create_test_task_with_exchanges(3);
@@ -7,8 +7,8 @@ use galaxy_core::ui::color::blend::Blend;
use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{ use galaxyui::elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
DropShadow, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, DropShadow, Element, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
ParentElement, Radius, Shrinkable, Text, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
}; };
use galaxyui::fonts::{Properties, Weight}; use galaxyui::fonts::{Properties, Weight};
use galaxyui::keymap::{FixedBinding, Keystroke}; use galaxyui::keymap::{FixedBinding, Keystroke};
@@ -22,7 +22,7 @@ use crate::appearance::Appearance;
use crate::ui_components::icons::Icon; use crate::ui_components::icons::Icon;
// Modal dimensions based on Figma design. // Modal dimensions based on Figma design.
const MODAL_WIDTH: f32 = 440.; const MODAL_WIDTH: f32 = 680.;
const DIALOG_CORNER_RADIUS: f32 = 8.; const DIALOG_CORNER_RADIUS: f32 = 8.;
const HEADER_PADDING_TOP: f32 = 24.; const HEADER_PADDING_TOP: f32 = 24.;
@@ -40,6 +40,7 @@ const OPTIONS_VERTICAL_GAP: f32 = 8.;
const AVATAR_SIZE: f32 = 48.; const AVATAR_SIZE: f32 = 48.;
const AVATAR_ICON_SIZE: f32 = 24.; const AVATAR_ICON_SIZE: f32 = 24.;
const OPTION_HEIGHT: f32 = 136.;
const TITLE_FONT_SIZE: f32 = 16.; const TITLE_FONT_SIZE: f32 = 16.;
const OPTION_TITLE_FONT_SIZE: f32 = 14.; const OPTION_TITLE_FONT_SIZE: f32 = 14.;
@@ -292,21 +293,25 @@ impl AgentTypeSelector {
) )
.finish(); .finish();
Container::new( ConstrainedBox::new(
Flex::row() Container::new(
.with_cross_axis_alignment(CrossAxisAlignment::Center) Flex::row()
.with_spacing(OPTION_GAP) .with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(avatar) .with_spacing(OPTION_GAP)
.with_child(Shrinkable::new(1., text_content).finish()) .with_child(avatar)
.finish(), .with_child(Shrinkable::new(1., text_content).finish())
.finish(),
)
.with_padding_left(OPTION_PADDING_HORIZONTAL)
.with_padding_right(OPTION_PADDING_HORIZONTAL)
.with_padding_top(OPTION_PADDING_VERTICAL)
.with_padding_bottom(OPTION_PADDING_VERTICAL)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_color(border_color))
.with_background(background)
.finish(),
) )
.with_padding_left(OPTION_PADDING_HORIZONTAL) .with_height(OPTION_HEIGHT)
.with_padding_right(OPTION_PADDING_HORIZONTAL)
.with_padding_top(OPTION_PADDING_VERTICAL)
.with_padding_bottom(OPTION_PADDING_VERTICAL)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OPTION_CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_color(border_color))
.with_background(background)
.finish() .finish()
}) })
.with_cursor(Cursor::PointingHand) .with_cursor(Cursor::PointingHand)
@@ -353,11 +358,11 @@ impl AgentTypeSelector {
appearance, appearance,
); );
let options = Flex::column() let options = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(OPTIONS_VERTICAL_GAP) .with_spacing(OPTIONS_VERTICAL_GAP)
.with_child(cloud_agent_option) .with_child(Expanded::new(1., cloud_agent_option).finish())
.with_child(local_agent_option) .with_child(Expanded::new(1., local_agent_option).finish())
.finish(); .finish();
let body = Container::new(options) let body = Container::new(options)
@@ -26,6 +26,7 @@ pub struct ActionButtonsConfig {
pub view_details_item_id: Option<AgentConversationEntryId>, pub view_details_item_id: Option<AgentConversationEntryId>,
/// Conversation link URL (either to the transcript or live session) for copy link button. /// Conversation link URL (either to the transcript or live session) for copy link button.
pub copy_link_url: Option<String>, pub copy_link_url: Option<String>,
pub delete_conversation_id: Option<AIConversationId>,
} }
impl ActionButtonsConfig { impl ActionButtonsConfig {
@@ -36,6 +37,7 @@ impl ActionButtonsConfig {
&& self.fork_conversation_id.is_none() && self.fork_conversation_id.is_none()
&& self.view_details_item_id.is_none() && self.view_details_item_id.is_none()
&& self.copy_link_url.is_none() && self.copy_link_url.is_none()
&& self.delete_conversation_id.is_none()
} }
/// Create config for a task. /// Create config for a task.
@@ -58,6 +60,7 @@ impl ActionButtonsConfig {
fork_conversation_id: None, fork_conversation_id: None,
view_details_item_id: None, view_details_item_id: None,
copy_link_url, copy_link_url,
delete_conversation_id: None,
} }
} }
@@ -75,6 +78,7 @@ impl ActionButtonsConfig {
fork_conversation_id: Some(conversation_id), fork_conversation_id: Some(conversation_id),
view_details_item_id: None, view_details_item_id: None,
copy_link_url, copy_link_url,
delete_conversation_id: Some(conversation_id),
} }
} }
} }
@@ -87,6 +91,7 @@ pub enum AgentDetailsButtonEvent {
ForkConversation { conversation_id: AIConversationId }, ForkConversation { conversation_id: AIConversationId },
ViewDetails { item_id: AgentConversationEntryId }, ViewDetails { item_id: AgentConversationEntryId },
CopyLink { link: String }, CopyLink { link: String },
DeleteConversation { conversation_id: AIConversationId },
} }
/// Actions dispatched by button clicks (internal). /// Actions dispatched by button clicks (internal).
@@ -97,6 +102,7 @@ pub enum AgentDetailsAction {
ForkConversation, ForkConversation,
ViewDetails, ViewDetails,
CopyLink, CopyLink,
DeleteConversation,
} }
/// Reusable action buttons row for details panel. /// Reusable action buttons row for details panel.
@@ -107,6 +113,7 @@ pub struct ConversationActionButtonsRow {
fork_conversation_button: ViewHandle<ActionButton>, fork_conversation_button: ViewHandle<ActionButton>,
view_details_button: ViewHandle<ActionButton>, view_details_button: ViewHandle<ActionButton>,
copy_link_button: ViewHandle<ActionButton>, copy_link_button: ViewHandle<ActionButton>,
delete_conversation_button: ViewHandle<ActionButton>,
} }
impl ConversationActionButtonsRow { impl ConversationActionButtonsRow {
@@ -156,6 +163,15 @@ impl ConversationActionButtonsRow {
) )
}); });
let delete_conversation_button = ctx.add_typed_action_view(|_| {
Self::make_action_button(
Icon::Trash,
"Delete conversation",
Some(AnsiColorIdentifier::Red),
AgentDetailsAction::DeleteConversation,
)
});
Self { Self {
config: ActionButtonsConfig::default(), config: ActionButtonsConfig::default(),
open_button, open_button,
@@ -163,6 +179,7 @@ impl ConversationActionButtonsRow {
fork_conversation_button, fork_conversation_button,
view_details_button, view_details_button,
copy_link_button, copy_link_button,
delete_conversation_button,
} }
} }
@@ -231,6 +248,9 @@ impl View for ConversationActionButtonsRow {
if self.config.view_details_item_id.is_some() { if self.config.view_details_item_id.is_some() {
row.add_child(ChildView::new(&self.view_details_button).finish()); row.add_child(ChildView::new(&self.view_details_button).finish());
} }
if self.config.delete_conversation_id.is_some() && !cfg!(target_family = "wasm") {
row.add_child(ChildView::new(&self.delete_conversation_button).finish());
}
row.finish() row.finish()
} }
@@ -280,6 +300,11 @@ impl TypedActionView for ConversationActionButtonsRow {
); );
} }
} }
AgentDetailsAction::DeleteConversation => {
if let Some(conversation_id) = self.config.delete_conversation_id {
ctx.emit(AgentDetailsButtonEvent::DeleteConversation { conversation_id });
}
}
} }
} }
} }
+39 -2
View File
@@ -1078,7 +1078,7 @@ impl AgentManagementView {
open_action: Option<WorkspaceAction>, open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>, copy_link_url: Option<String>,
) -> ActionButtonsConfig { ) -> ActionButtonsConfig {
if let Some(task_id) = entry.identity.ambient_agent_task_id { let mut config = if let Some(task_id) = entry.identity.ambient_agent_task_id {
ActionButtonsConfig::for_task( ActionButtonsConfig::for_task(
task_id, task_id,
&entry.display.status, &entry.display.status,
@@ -1093,7 +1093,15 @@ impl AgentManagementView {
copy_link_url, copy_link_url,
..Default::default() ..Default::default()
} }
};
if !entry.capabilities.can_delete
|| !entry.display.status.to_conversation_status().is_done()
{
config.delete_conversation_id = None;
} }
config
} }
fn handle_action_buttons_event( fn handle_action_buttons_event(
@@ -1173,6 +1181,18 @@ impl AgentManagementView {
ctx.clipboard() ctx.clipboard()
.write(ClipboardContent::plain_text(link.clone())); .write(ClipboardContent::plain_text(link.clone()));
} }
AgentDetailsButtonEvent::DeleteConversation { conversation_id } => {
let model = AgentConversationsModel::as_ref(ctx);
let conversation_title = model
.get_entry_by_id(item_id, ctx)
.map(|entry| entry.display.title)
.unwrap_or_else(|| "Conversation".to_string());
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
conversation_title,
terminal_view_id: None,
});
}
} }
} }
@@ -1395,6 +1415,16 @@ impl AgentManagementView {
notebook_uid: *notebook_uid, notebook_uid: *notebook_uid,
}); });
} }
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
conversation_id,
conversation_title,
} => {
ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
conversation_title: conversation_title.clone(),
terminal_view_id: None,
});
}
} }
} }
@@ -2232,7 +2262,14 @@ pub enum AgentManagementViewAction {
pub enum AgentManagementViewEvent { pub enum AgentManagementViewEvent {
OpenNewTabAndRunWorkflow(Box<WorkflowType>), OpenNewTabAndRunWorkflow(Box<WorkflowType>),
OpenPlanNotebook { notebook_uid: NotebookId }, OpenPlanNotebook {
notebook_uid: NotebookId,
},
ShowDeleteConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>,
},
} }
impl TypedActionView for AgentManagementView { impl TypedActionView for AgentManagementView {
+18 -1
View File
@@ -26,6 +26,7 @@ pub mod text {
pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> { pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> {
match input { match input {
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::CreateNewProject { .. } | AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. } | AIAgentInput::CloneRepository { .. }
@@ -60,6 +61,9 @@ pub mod text {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
writeln!(w, "{CANCELLED_MESSAGE}") writeln!(w, "{CANCELLED_MESSAGE}")
} }
RequestCommandOutputResult::ExecutionError { command, message } => {
writeln!(w, "Command `{command}` was not executed: {message}")
}
RequestCommandOutputResult::Denylisted { .. } => { RequestCommandOutputResult::Denylisted { .. } => {
writeln!( writeln!(
w, w,
@@ -432,6 +436,12 @@ pub mod text {
AIAgentActionType::RunAgents(_) => (), AIAgentActionType::RunAgents(_) => (),
AIAgentActionType::WaitForEvents { .. } => (), AIAgentActionType::WaitForEvents { .. } => (),
}, },
AIAgentOutputMessageType::RuntimeActivity(activity) => {
writeln!(w, "{}", activity.title)?;
if let Some(output) = &activity.output {
writeln!(w, "{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(operation) => match operation { AIAgentOutputMessageType::TodoOperation(operation) => match operation {
TodoOperation::UpdateTodos { todos } => { TodoOperation::UpdateTodos { todos } => {
writeln!(w, "Updated TODO list:")?; writeln!(w, "Updated TODO list:")?;
@@ -779,6 +789,7 @@ pub mod json {
match input { match input {
// Do not include the user query, since it's already provided as input to the agent. // Do not include the user query, since it's already provided as input to the agent.
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::CreateNewProject { .. } | AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. } | AIAgentInput::CloneRepository { .. }
@@ -821,6 +832,11 @@ pub mod json {
RequestCommandOutputResult::CancelledBeforeExecution => { RequestCommandOutputResult::CancelledBeforeExecution => {
Some(JsonMessage::ToolCanceled) Some(JsonMessage::ToolCanceled)
} }
RequestCommandOutputResult::ExecutionError { message, .. } => {
Some(JsonMessage::ToolError {
error: Cow::Borrowed(message),
})
}
RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError { RequestCommandOutputResult::Denylisted { .. } => Some(JsonMessage::ToolError {
error: Cow::Borrowed( error: Cow::Borrowed(
"Command was not allowed to run due to presence on denylist", "Command was not allowed to run due to presence on denylist",
@@ -1144,7 +1160,8 @@ pub mod json {
}) })
} }
AIAgentOutputMessageType::MessagesReceivedFromAgents { .. } AIAgentOutputMessageType::MessagesReceivedFromAgents { .. }
| AIAgentOutputMessageType::EventsFromAgents { .. } => None, | AIAgentOutputMessageType::EventsFromAgents { .. }
| AIAgentOutputMessageType::RuntimeActivity(_) => None,
} }
} }
} }
+24 -4
View File
@@ -5,13 +5,15 @@ use aws_config::BehaviorVersion;
use aws_credential_types::provider::ProvideCredentials; use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_bedrockruntime::config::Region; use aws_sdk_bedrockruntime::config::Region;
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use galaxy_agent_core::AgentError;
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition}; use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger; use super::diagnostic::BedrockDiagnosticLogger;
use super::external_config::ExternalBedrockConfig; use super::external_config::ExternalBedrockConfig;
use super::models::apply_cross_region_prefix; use super::models::apply_cross_region_prefix;
use super::response_translator::bedrock_stream_to_response_events; use super::response_translator::bedrock_stream_to_response_events;
use crate::ai::agent::api::ResponseStream; use super::runtime::BedrockAgentRuntime;
use crate::ai::agent::api::LegacyResponseStream;
use crate::settings::ai::BedrockAuthMethod; use crate::settings::ai::BedrockAuthMethod;
fn strip_context_marker(model_id: &str) -> String { fn strip_context_marker(model_id: &str) -> String {
@@ -38,6 +40,7 @@ pub struct BedrockClientConfig {
pub secret_access_key: String, pub secret_access_key: String,
pub session_token: Option<String>, pub session_token: Option<String>,
pub cross_region_inference: bool, pub cross_region_inference: bool,
pub use_rig: bool,
} }
impl BedrockClientConfig { impl BedrockClientConfig {
@@ -141,8 +144,8 @@ impl BedrockClient {
match provider.provide_credentials().await { match provider.provide_credentials().await {
Ok(creds) => { Ok(creds) => {
log::info!( log::info!(
"[bedrock] Resolved AWS credentials successfully: access_key_id={:?}, has_session_token={}, expiry={:?}", "[bedrock] Resolved AWS credentials successfully: has_access_key_id={}, has_session_token={}, expiry={:?}",
creds.access_key_id(), !creds.access_key_id().is_empty(),
creds.session_token().is_some(), creds.session_token().is_some(),
creds.expiry(), creds.expiry(),
); );
@@ -168,6 +171,23 @@ impl BedrockClient {
}) })
} }
pub(crate) fn agent_runtime(
&self,
model: String,
cross_region_inference: bool,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> Result<BedrockAgentRuntime, AgentError> {
BedrockAgentRuntime::new(
self.runtime_client.clone(),
model,
self.region.clone(),
cross_region_inference,
max_output_tokens,
caching_config,
)
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn converse_stream( pub async fn converse_stream(
&self, &self,
@@ -185,7 +205,7 @@ impl BedrockClient {
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>, diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>, messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
tool_result_archive: Vec<ConversationMessage>, tool_result_archive: Vec<ConversationMessage>,
) -> Result<ResponseStream, BedrockError> { ) -> Result<LegacyResponseStream, BedrockError> {
let base_model_id = strip_context_marker(model_id); let base_model_id = strip_context_marker(model_id);
let effective_model_id = if cross_region_inference { let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(&base_model_id, &self.region) apply_cross_region_prefix(&base_model_id, &self.region)
+12 -2
View File
@@ -3,8 +3,9 @@ use std::collections::HashMap;
use aws_sdk_bedrockruntime::types::{ use aws_sdk_bedrockruntime::types::{
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock, CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock,
ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage, ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage,
SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, ToolResultBlock, ReasoningContentBlock, ReasoningTextBlock, SystemContentBlock, Tool, ToolConfiguration,
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock, ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
ToolUseBlock,
}; };
use aws_smithy_types::{Blob, Document}; use aws_smithy_types::{Blob, Document};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
@@ -148,6 +149,15 @@ fn convert_messages(
.into_iter() .into_iter()
.map(|part| match part { .map(|part| match part {
ContentPart::Text(text) => ContentBlock::Text(text), ContentPart::Text(text) => ContentBlock::Text(text),
ContentPart::Reasoning { text, signature } => {
ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(
ReasoningTextBlock::builder()
.text(text)
.set_signature(signature)
.build()
.expect("valid reasoning text block"),
))
}
ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type), ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type),
ContentPart::ToolUse { ContentPart::ToolUse {
tool_use_id, tool_use_id,
+8 -2
View File
@@ -258,7 +258,10 @@ fn test_system_prompt_separated_from_messages() {
None, None,
None, None,
None, None,
CachingConfig::default(), CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
); );
assert_eq!(result.system.len(), 1); assert_eq!(result.system.len(), 1);
@@ -334,7 +337,10 @@ fn test_tool_definitions_produce_tool_config() {
None, None,
None, None,
None, None,
CachingConfig::default(), CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
); );
assert!(result.tool_config.is_some()); assert!(result.tool_config.is_some());
+8
View File
@@ -492,6 +492,14 @@ fn serialize_messages(messages: &[ConversationMessage]) -> JsonValue {
super::convert::ContentPart::Text(t) => { super::convert::ContentPart::Text(t) => {
serde_json::json!({"type": "text", "text": t}) serde_json::json!({"type": "text", "text": t})
} }
super::convert::ContentPart::Reasoning { text, signature } => {
serde_json::json!({
"type": "reasoning",
"char_length": text.len(),
"has_signature": signature.is_some(),
"text": "REDACTED",
})
}
super::convert::ContentPart::Image { data, mime_type } => { super::convert::ContentPart::Image { data, mime_type } => {
serde_json::json!({ serde_json::json!({
"type": "image", "type": "image",
+193
View File
@@ -0,0 +1,193 @@
//! AWS Bedrock control-plane discovery.
//!
//! The foundation-model catalog is only a candidate list. Every candidate is
//! checked with `GetFoundationModelAvailability` before it is offered to the
//! user or persisted in Galaxy settings.
use aws_config::BehaviorVersion;
use aws_sdk_bedrock::Client;
use aws_sdk_bedrockruntime::config::Region;
use super::client::{BedrockClientConfig, BedrockError};
use crate::settings::ai::BedrockModelConfig;
pub async fn discover_available_models(
config: BedrockClientConfig,
) -> Result<Vec<BedrockModelConfig>, String> {
let aws_config = load_aws_config(&config)
.await
.map_err(|error| error.to_string())?;
let client = Client::new(&aws_config);
let catalog = client
.list_foundation_models()
.send()
.await
.map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?;
let mut models = Vec::new();
for summary in catalog.model_summaries() {
let model_id = summary.model_id();
let availability = match client
.get_foundation_model_availability()
.model_id(model_id)
.send()
.await
{
Ok(availability) => availability,
Err(error) => {
log::debug!(
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
);
continue;
}
};
if !model_availability_is_usable(
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str()),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
) {
log::debug!(
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
availability
.agreement_availability()
.map(|agreement| agreement.status().as_str())
.unwrap_or("MISSING"),
availability.authorization_status().as_str(),
availability.entitlement_availability().as_str(),
availability.region_availability().as_str(),
);
continue;
}
let display_name = summary
.model_name()
.map(str::to_owned)
.unwrap_or_else(|| prettify_model_id(model_id));
let vision_supported = summary
.input_modalities()
.iter()
.any(|modality| modality.as_str() == "IMAGE");
models.push(BedrockModelConfig {
model_id: model_id.to_owned(),
display_name,
vision_supported,
use_rig: false,
});
}
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
if models.is_empty() {
return Err(
"AWS returned no Bedrock models that are authorized and available in this region."
.to_string(),
);
}
Ok(models)
}
fn model_availability_is_usable(
agreement_status: Option<&str>,
authorization_status: &str,
entitlement_status: &str,
region_status: &str,
) -> bool {
agreement_status == Some("AVAILABLE")
&& authorization_status == "AUTHORIZED"
&& entitlement_status == "AVAILABLE"
&& region_status == "AVAILABLE"
}
async fn load_aws_config(
config: &BedrockClientConfig,
) -> Result<aws_config::SdkConfig, BedrockError> {
let sdk_config = match config.auth_method {
crate::settings::ai::BedrockAuthMethod::Profile
| crate::settings::ai::BedrockAuthMethod::Sso => {
let mut loader = aws_config::defaults(BehaviorVersion::latest());
if !config.profile.is_empty() && config.profile != "default" {
loader = loader.profile_name(&config.profile);
}
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
}
loader.load().await
}
crate::settings::ai::BedrockAuthMethod::StaticKeys => {
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
return Err(BedrockError::CredentialsNotConfigured);
}
let credentials = aws_credential_types::Credentials::new(
&config.access_key_id,
&config.secret_access_key,
config.session_token.clone(),
None,
"galaxy-bedrock-discovery",
);
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(credentials);
loader = loader.region(Region::new(if config.region.is_empty() {
"us-east-1".to_string()
} else {
config.region.clone()
}));
loader.load().await
}
};
if sdk_config.region().is_none() {
return Err(BedrockError::RegionNotConfigured);
}
Ok(sdk_config)
}
fn prettify_model_id(model_id: &str) -> String {
model_id
.rsplit('.')
.next()
.unwrap_or(model_id)
.replace(['-', ':'], " ")
}
#[cfg(test)]
mod tests {
use super::model_availability_is_usable;
#[test]
fn requires_every_availability_status() {
assert!(model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
None,
"AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"NOT_AUTHORIZED",
"AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"NOT_AVAILABLE",
"AVAILABLE",
));
assert!(!model_availability_is_usable(
Some("AVAILABLE"),
"AUTHORIZED",
"AVAILABLE",
"NOT_AVAILABLE",
));
}
}
+1
View File
@@ -269,6 +269,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(), secret_access_key: String::new(),
session_token: None, session_token: None,
cross_region_inference: false, cross_region_inference: false,
use_rig: false,
}) })
} }
+1
View File
@@ -144,6 +144,7 @@ fn parse_claude_code_model_map(
model_id: arn, model_id: arn,
display_name, display_name,
vision_supported: true, vision_supported: true,
use_rig: false,
} }
}) })
.collect() .collect()
+1
View File
@@ -24,6 +24,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(), secret_access_key: String::new(),
session_token: None, session_token: None,
cross_region_inference: false, cross_region_inference: false,
use_rig: false,
}) })
} }
+2 -1
View File
@@ -2,12 +2,13 @@ pub mod client;
pub mod convert; pub mod convert;
pub mod crash_log; pub mod crash_log;
pub mod diagnostic; pub mod diagnostic;
pub mod discovery;
pub mod external_config; pub mod external_config;
pub mod models; pub mod models;
pub mod request_translator; pub mod request_translator;
pub mod response_translator; pub mod response_translator;
pub mod runtime;
pub mod settings_view; pub mod settings_view;
pub mod translator;
#[cfg(test)] #[cfg(test)]
mod convert_tests; mod convert_tests;
+23 -143
View File
@@ -1,152 +1,32 @@
#![allow(dead_code)] #![allow(dead_code)]
use super::external_config::ExternalBedrockConfig;
use crate::settings::ai::BedrockModelConfig; use crate::settings::ai::BedrockModelConfig;
pub struct DefaultModel { pub fn configured_model_uses_rig(
pub model_id: &'static str, selected_model_id: &str,
pub display_name: &'static str, configured_models: &[BedrockModelConfig],
pub vision_supported: bool, region: &str,
pub context_size: u32, cross_region_inference: bool,
) -> bool {
let selected_model_id = strip_context_marker(selected_model_id);
configured_models.iter().any(|model| {
if !model.use_rig {
return false;
}
let configured_model_id = strip_context_marker(&model.model_id);
if configured_model_id == selected_model_id {
return true;
}
galaxy_agent_rig::resolve_bedrock_model_id(&model.model_id, region, cross_region_inference)
.is_ok_and(|resolved| strip_context_marker(&resolved) == selected_model_id)
})
} }
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[ fn strip_context_marker(model_id: &str) -> &str {
DefaultModel { model_id
model_id: "us.anthropic.claude-opus-4-6-v1[1m]", .strip_suffix("[1m]")
display_name: "Claude Opus 4.6 (1M)", .or_else(|| model_id.strip_suffix("[1M]"))
vision_supported: true, .unwrap_or(model_id)
context_size: 1_000_000,
},
DefaultModel {
model_id: "us.anthropic.claude-sonnet-4-6[1m]",
display_name: "Claude Sonnet 4.6 (1M)",
vision_supported: true,
context_size: 1_000_000,
},
DefaultModel {
model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
display_name: "Claude Sonnet 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0",
display_name: "Claude 3 Sonnet",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
display_name: "Claude Sonnet 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0",
display_name: "Claude Opus 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0",
display_name: "Claude Opus 4.1",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-opus-4-6-v1",
display_name: "Claude Opus 4.6 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0",
display_name: "Claude Opus 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-3-haiku-20240307-v1:0",
display_name: "Claude 3 Haiku",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
display_name: "Claude 3.5 Haiku",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
];
pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockModelConfig> {
if !user_models.is_empty() {
return user_models.to_vec();
}
// Fall back to models from external configs (Claude Code / OpenCode)
let external = ExternalBedrockConfig::load();
if !external.models.is_empty() {
log::info!(
"[bedrock] Using {} model(s) from external config",
external.models.len()
);
// Merge external models with defaults so the user still sees all defaults
let mut models = external.models;
let defaults: Vec<BedrockModelConfig> = DEFAULT_BEDROCK_MODELS
.iter()
.map(|m| BedrockModelConfig {
model_id: m.model_id.to_string(),
display_name: m.display_name.to_string(),
vision_supported: m.vision_supported,
})
.collect();
for default in defaults {
if !models.iter().any(|m| m.model_id == default.model_id) {
models.push(default);
}
}
return models;
}
DEFAULT_BEDROCK_MODELS
.iter()
.map(|m| BedrockModelConfig {
model_id: m.model_id.to_string(),
display_name: m.display_name.to_string(),
vision_supported: m.vision_supported,
})
.collect()
} }
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String { pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
+29 -20
View File
@@ -78,28 +78,37 @@ fn test_cross_region_prefix_unknown_region() {
); );
} }
#[test]
fn test_get_effective_models_empty_returns_defaults() {
let models = get_effective_models(&[]);
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6[1m]");
assert_eq!(models[0].display_name, "Claude Opus 4.6");
}
#[test]
fn test_get_effective_models_custom_overrides() {
let custom = vec![BedrockModelConfig {
model_id: "custom.model-v1:0".to_string(),
display_name: "Custom Model".to_string(),
vision_supported: false,
}];
let models = get_effective_models(&custom);
assert_eq!(models.len(), 1);
assert_eq!(models[0].model_id, "custom.model-v1:0");
}
#[test] #[test]
fn test_cross_region_prefix_skips_arn() { fn test_cross_region_prefix_skips_arn() {
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy"; let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn); assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
} }
#[test]
fn rig_opt_in_matches_context_markers_and_resolved_inference_profiles() {
let configured = vec![BedrockModelConfig {
model_id: "anthropic.claude-test[1m]".to_string(),
display_name: "Claude Test".to_string(),
vision_supported: false,
use_rig: true,
}];
assert!(configured_model_uses_rig(
"us.anthropic.claude-test",
&configured,
"us-east-1",
true,
));
assert!(configured_model_uses_rig(
"anthropic.claude-test[1M]",
&configured,
"us-east-1",
false,
));
assert!(!configured_model_uses_rig(
"anthropic.other-model",
&configured,
"us-east-1",
false,
));
}
+188 -67
View File
@@ -7,6 +7,7 @@ use warp_multi_agent_api as api;
use super::convert::{ use super::convert::{
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition, ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
}; };
use crate::ai::agent::api::mark_internal_command_completion_assessment;
/// Command-monitor turns must wake often enough to react to steering and user-specified deadlines. /// Command-monitor turns must wake often enough to react to steering and user-specified deadlines.
/// ///
@@ -91,32 +92,11 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
) => { ) => {
if let Some(user_query) = &cli_query.user_query { if let Some(user_query) = &cli_query.user_query {
if !user_query.query.is_empty() { if !user_query.query.is_empty() {
let query_text =
if let Some(running_cmd) = &cli_query.running_command {
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!(
"[Command ID: {}]\n",
snapshot.command_id
));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
} else {
user_query.query.clone()
};
user_queries.push(ConversationMessage { user_queries.push(ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::Text(query_text), content: MessageContent::Text(cli_query_text(
cli_query, user_query,
)),
}); });
} }
} }
@@ -565,24 +545,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
) => { ) => {
if let Some(user_query) = &cli_query.user_query { if let Some(user_query) = &cli_query.user_query {
if !user_query.query.is_empty() { if !user_query.query.is_empty() {
let query_text = let mut message = api::Message {
if let Some(running_cmd) = &cli_query.running_command {
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
} else {
user_query.query.clone()
};
results.push(api::Message {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(), task_id: task_id.clone(),
request_id: String::new(), request_id: String::new(),
@@ -592,11 +555,15 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
fetched_memories: vec![], fetched_memories: vec![],
message: Some(api::message::Message::UserQuery( message: Some(api::message::Message::UserQuery(
api::message::UserQuery { api::message::UserQuery {
query: query_text, query: cli_query_text(cli_query, user_query),
..Default::default() ..Default::default()
}, },
)), )),
}); };
if cli_query_is_completed_assessment(cli_query) {
mark_internal_command_completion_assessment(&mut message);
}
results.push(message);
} }
} }
} }
@@ -750,6 +717,7 @@ fn persist_input_images_on_latest_user_message(
Some(api::input_context::Image { data, mime_type }) Some(api::input_context::Image { data, mime_type })
} }
Some(ContentPart::Text(_)) Some(ContentPart::Text(_))
| Some(ContentPart::Reasoning { .. })
| Some(ContentPart::ToolUse { .. }) | Some(ContentPart::ToolUse { .. })
| Some(ContentPart::ToolResult { .. }) | Some(ContentPart::ToolResult { .. })
| None => None, | None => None,
@@ -873,11 +841,17 @@ fn is_pure_tool_result(content: &MessageContent) -> bool {
fn strip_tool_result_parts(content: &mut MessageContent) { fn strip_tool_result_parts(content: &mut MessageContent) {
if let MessageContent::MultiPart(parts) = content { if let MessageContent::MultiPart(parts) = content {
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. })); parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) { if parts.len() == 1
&& !matches!(
parts.first(),
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
)
{
let part = parts.remove(0); let part = parts.remove(0);
*content = match part { *content = match part {
ContentPart::Text(t) => MessageContent::Text(t), ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::Image { .. } => unreachable!(), ContentPart::Image { .. } => unreachable!(),
ContentPart::Reasoning { .. } => unreachable!(),
ContentPart::ToolUse { ContentPart::ToolUse {
tool_use_id, tool_use_id,
name, name,
@@ -916,11 +890,17 @@ fn strip_orphaned_tool_result_parts(
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id), ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
_ => true, _ => true,
}); });
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) { if parts.len() == 1
&& !matches!(
parts.first(),
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
)
{
let part = parts.remove(0); let part = parts.remove(0);
*content = match part { *content = match part {
ContentPart::Text(t) => MessageContent::Text(t), ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::Image { .. } => unreachable!(), ContentPart::Image { .. } => unreachable!(),
ContentPart::Reasoning { .. } => unreachable!(),
ContentPart::ToolUse { ContentPart::ToolUse {
tool_use_id, tool_use_id,
name, name,
@@ -1222,12 +1202,57 @@ fn collect_tool_result_ids(content: &MessageContent, ids: &mut std::collections:
} }
} }
fn cli_query_is_completed_assessment(cli_query: &api::request::input::CliAgentUserQuery) -> bool {
cli_query
.user_query
.as_ref()
.is_some_and(|query| query.intended_agent() == api::AgentType::Primary)
}
fn cli_query_text(
cli_query: &api::request::input::CliAgentUserQuery,
user_query: &api::request::input::UserQuery,
) -> String {
let Some(command) = &cli_query.running_command else {
return user_query.query.clone();
};
let completed = cli_query_is_completed_assessment(cli_query);
let mut context = format!(
"[{}: {}]\n",
if completed {
"Completed command"
} else {
"Running command"
},
command.command
);
if let Some(snapshot) = &command.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!("[Command ID: {}]\n", snapshot.command_id));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[{}:\n{}\n]\n",
if completed {
"Final terminal output"
} else {
"Terminal output"
},
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentMode { enum AgentMode {
Normal, Normal,
Plan, Plan,
Orchestrate, Orchestrate,
Cli, Cli,
CompletedCommandAssessment,
} }
fn request_agent_mode(request: &api::Request) -> AgentMode { fn request_agent_mode(request: &api::Request) -> AgentMode {
@@ -1239,6 +1264,17 @@ fn request_agent_mode(request: &api::Request) -> AgentMode {
return AgentMode::Normal; return AgentMode::Normal;
}; };
if user_inputs.inputs.iter().any(|user_input| {
matches!(
&user_input.input,
Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
cli_query
)) if cli_query_is_completed_assessment(cli_query)
)
}) {
return AgentMode::CompletedCommandAssessment;
}
let mut mode = AgentMode::Normal; let mut mode = AgentMode::Normal;
for user_input in &user_inputs.inputs { for user_input in &user_inputs.inputs {
match &user_input.input { match &user_input.input {
@@ -1483,16 +1519,31 @@ pub fn extract_system_prompt(
"This turn concerns a running or just-finished shell command. Act as its dedicated \ "This turn concerns a running or just-finished shell command. Act as its dedicated \
monitor while still following the user's steering messages. Use the command ID from \ monitor while still following the user's steering messages. Use the command ID from \
the running-command context or tool result for every read/write operation. If the \ the running-command context or tool result for every read/write operation. If the \
result says the command finished, report its outcome and stop polling. Otherwise, \ result says the command finished, report its outcome and stop polling. If it says the \
poll with `read_shell_command_output` and use short delays. Never choose a poll \ command is still running, the next assistant output MUST be a tool call. Use \
interval that crosses a user-specified deadline or stop condition. When an explicit \ `read_shell_command_output` with a short delay for normal progress. If the snapshot \
stop condition is met, call `interrupt_shell_command` immediately, then poll briefly \ clearly shows an interactive pager or editor, do not keep polling: an alternate screen \
to verify the outcome. Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or \ containing `(END)` is `less`, so call `write_to_long_running_shell_command` with input \
`\\u0003` through `write_to_long_running_shell_command`; that tool is only for actual \ `q` and mode `raw`; for a clearly identified Vim screen, send input `:q` with mode \
process input. Never start a duplicate command merely to check its state, and never \ `line`. Poll briefly after sending quit input to verify the outcome. Call \
report completion while a result says it is still running. If user interaction is \ `interrupt_shell_command` immediately when the user's explicit stop condition is met. \
the right next step and the transfer tool is available, transfer control with a \ Do not end a still-running monitor turn with prose, a status message, or a request for \
clear reason.\n\n", the user to say continue. Never choose a poll interval that crosses a user-specified \
deadline or stop condition. After an interrupt, poll briefly to verify the outcome. \
Never try to encode Ctrl+C as `C-c`, `^C`, `\\x03`, or `\\u0003` through \
`write_to_long_running_shell_command`; that tool is only for actual process input. \
Never start a duplicate command merely to check its state, and never report completion \
while a result says it is still running. If user interaction is the right next step and \
the transfer tool is available, transfer control with a clear reason.\n\n",
);
}
AgentMode::CompletedCommandAssessment => {
prompt.push_str("## Completed Command Assessment\n");
prompt.push_str(
"The monitored command has finished. Use its command, command ID, final terminal \
output, and the assessment instruction in the latest hidden input to provide the \
final user-facing outcome. Do not continue polling, request more terminal output, \
or call tools.\n\n",
); );
} }
} }
@@ -1543,6 +1594,10 @@ pub fn extract_system_prompt(
} }
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> { pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
if request_agent_mode(request) == AgentMode::CompletedCommandAssessment {
return Vec::new();
}
let mut tools = default_tool_definitions(); let mut tools = default_tool_definitions();
let mut seen_names: std::collections::HashSet<String> = let mut seen_names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name.clone()).collect(); tools.iter().map(|t| t.name.clone()).collect();
@@ -1604,10 +1659,12 @@ pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> { fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> {
let settings = request.settings.as_ref()?; let settings = request.settings.as_ref()?;
let raw_tools = if request_agent_mode(request) == AgentMode::Cli { let raw_tools = match request_agent_mode(request) {
&settings.supported_cli_agent_tools AgentMode::Cli => &settings.supported_cli_agent_tools,
} else { AgentMode::Normal
&settings.supported_tools | AgentMode::Plan
| AgentMode::Orchestrate
| AgentMode::CompletedCommandAssessment => &settings.supported_tools,
}; };
Some( Some(
raw_tools raw_tools
@@ -1617,7 +1674,7 @@ fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>
) )
} }
fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool { pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
use api::ToolType; use api::ToolType;
let has = |tool| supported.contains(&tool); let has = |tool| supported.contains(&tool);
@@ -1639,7 +1696,7 @@ fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> boo
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments), "read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments), "create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments), "edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
"start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2), "run_agents" | "start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
"ask_user_question" => has(ToolType::AskUserQuestion), "ask_user_question" => has(ToolType::AskUserQuestion),
"read_skill" => has(ToolType::ReadSkill), "read_skill" => has(ToolType::ReadSkill),
"fetch_conversation" => has(ToolType::FetchConversation), "fetch_conversation" => has(ToolType::FetchConversation),
@@ -1841,7 +1898,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
}, },
ToolDefinition { ToolDefinition {
name: "create_plan".to_string(), name: "create_plan".to_string(),
description: "Create a new plan document in Galaxy Drive's Plans folder. Plans are rich-text documents for tracking tasks, architecture decisions, and project notes.".to_string(), description: "Create a new plan document in Galaxy Drive's Plans folder. When the user asks to create a plan for review, use this tool after completing the necessary research instead of only returning plan prose. Plans are rich-text documents for tracking tasks, architecture decisions, and project notes.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1894,6 +1951,68 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
"required": ["diffs"] "required": ["diffs"]
}), }),
}, },
ToolDefinition {
name: "run_agents".to_string(),
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and inherit the parent model. After launch, call wait_for_events when you need child-agent results instead of repeating their work yourself.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"summary": { "type": "string", "description": "Brief explanation of why child agents help with this task" },
"base_prompt": { "type": "string", "default": "", "description": "Instructions prepended to every child prompt" },
"skills": {
"type": "array",
"items": {
"type": "object",
"properties": {
"skill": { "type": "string" },
"reference_type": { "type": "string", "enum": ["path", "bundled"] }
},
"required": ["skill", "reference_type"]
}
},
"model_id": { "type": "string", "default": "", "description": "Optional child model override; empty inherits the parent model" },
"harness_type": { "type": "string", "default": "", "description": "Optional harness identifier; empty selects the embedded local child runtime" },
"execution_mode": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["local", "remote"], "default": "local" },
"environment_id": { "type": "string", "default": "" },
"worker_host": { "type": "string", "default": "" },
"computer_use_enabled": { "type": "boolean", "default": false }
}
},
"agent_run_configs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Unique child name" },
"prompt": { "type": "string", "default": "", "description": "Child-specific instructions" },
"title": { "type": "string", "default": "", "description": "Optional display title" }
},
"required": ["name", "prompt"]
}
},
"plan_id": { "type": "string", "default": "", "description": "Optional associated plan document ID" }
},
"required": ["summary", "agent_run_configs"]
}),
},
ToolDefinition {
name: "wait_for_events".to_string(),
description: "Yield after starting child agents or other asynchronous work. Use this when you are waiting for child-agent updates instead of repeating the same investigation yourself.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"idle_timeout_seconds": {
"type": "integer",
"default": 0,
"description": "Optional idle timeout. 0 lets Galaxy choose the default."
}
}
}),
},
ToolDefinition { ToolDefinition {
name: "start_agent".to_string(), name: "start_agent".to_string(),
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(), description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
@@ -2380,10 +2499,12 @@ fn long_running_command_content(snapshot: &api::LongRunningShellCommandSnapshot)
}; };
format!( format!(
"Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\ "Command is still running.\nCommand ID: {}\nCurrent terminal output:\n{}\n\
Continue monitoring with `read_shell_command_output` using command_id `{}`. \ The next assistant output MUST be a tool call: continue monitoring with \
Use `write_to_long_running_shell_command` with the same command_id only if input is \ `read_shell_command_output` using command_id `{}` and a short wait. Use \
required. If the user's explicit stop condition is met, use `interrupt_shell_command` \ `write_to_long_running_shell_command` with the same command_id only if input is required. \
with the same command_id. Do not report the command as complete while it is still running.", If the user's explicit stop condition is met, use `interrupt_shell_command` immediately \
with the same command_id. Do not end this turn with prose or report the command as complete \
while it is still running.",
snapshot.command_id, output, snapshot.command_id snapshot.command_id, output, snapshot.command_id
) )
} }
@@ -5,6 +5,7 @@ use super::{
convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt, convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt,
extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock, extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock,
}; };
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole}; use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
#[test] #[test]
@@ -99,6 +100,7 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
vec![ vec![
"run_shell_command", "run_shell_command",
"read_files", "read_files",
"run_agents",
"start_agent", "start_agent",
"recall_tool_history" "recall_tool_history"
] ]
@@ -236,6 +238,117 @@ fn plan_mode_prompt_prohibits_mutation() {
assert!(prompt.contains("do not edit files")); assert!(prompt.contains("do not edit files"));
} }
fn completed_command_request() -> api::Request {
api::Request {
task_context: Some(api::request::TaskContext {
tasks: vec![api::Task {
id: "root-task".to_string(),
..Default::default()
}],
}),
input: Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(
api::request::input::UserInputs {
inputs: vec![api::request::input::user_inputs::UserInput {
input: Some(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: "Report the final outcome.".to_string(),
intended_agent: api::AgentType::Primary.into(),
..Default::default()
}),
running_command: Some(api::RunningShellCommand {
command: "cargo test -p galaxy".to_string(),
snapshot: Some(api::LongRunningShellCommandSnapshot {
command_id: "completed-block-123".to_string(),
output: "test result: ok".to_string(),
cursor: "cursor".to_string(),
..Default::default()
}),
}),
..Default::default()
},
),
),
}],
},
)),
..Default::default()
}),
settings: Some(api::request::Settings {
supported_tools: vec![
api::ToolType::RunShellCommand.into(),
api::ToolType::ReadFiles.into(),
api::ToolType::CallMcpTool.into(),
],
supported_cli_agent_tools: vec![api::ToolType::ReadShellCommandOutput.into()],
..Default::default()
}),
mcp_context: Some(api::request::McpContext {
servers: vec![api::request::mcp_context::McpServer {
id: "server-id".to_string(),
name: "test-server".to_string(),
description: String::new(),
resources: Vec::new(),
tools: vec![api::request::mcp_context::McpTool {
name: "echo".to_string(),
description: "Echo input".to_string(),
input_schema: None,
}],
}],
..Default::default()
}),
..Default::default()
}
}
#[test]
fn completed_command_assessment_is_tool_free_and_persists_hidden_provider_history() {
let mut request = completed_command_request();
let messages = extract_new_input_messages(&request);
assert_eq!(messages.len(), 1);
assert!(matches!(
&messages[0].content,
MessageContent::Text(text)
if text.contains("[Completed command: cargo test -p galaxy]")
&& text.contains("[Command ID: completed-block-123]")
&& text.contains("[Final terminal output:\ntest result: ok")
&& text.contains("Report the final outcome.")
));
let prompt = extract_system_prompt(&request, &[]).expect("system prompt");
assert!(prompt.contains("## Completed Command Assessment"));
assert!(prompt.contains("No tools are available for this request"));
assert!(!prompt.contains("## Running Command Monitor"));
assert!(!prompt.contains("next assistant output MUST be a tool call"));
assert!(extract_tools(&request).is_empty());
inject_input_messages_into_task(&mut request);
let persisted = &request.task_context.as_ref().expect("task context").tasks[0].messages;
assert_eq!(persisted.len(), 1);
assert!(is_internal_command_completion_assessment(&persisted[0]));
assert!(matches!(
persisted[0].message.as_ref(),
Some(api::message::Message::UserQuery(query))
if query.query.contains("[Completed command: cargo test -p galaxy]")
&& query.query.contains("[Command ID: completed-block-123]")
&& query.query.contains("[Final terminal output:\ntest result: ok")
&& query.query.contains("Report the final outcome.")
));
let restored = convert_proto_message_for_test(&persisted[0])
.expect("hidden assessment should remain in provider history");
assert_eq!(restored.role, MessageRole::User);
assert!(matches!(
restored.content,
MessageContent::Text(text)
if text.contains("[Completed command: cargo test -p galaxy]")
&& text.contains("Report the final outcome.")
));
}
#[test] #[test]
fn running_command_turn_gets_monitor_prompt_and_cli_tools() { fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
let request = api::Request { let request = api::Request {
@@ -299,6 +412,10 @@ fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
assert!(prompt.contains("command ID")); assert!(prompt.contains("command ID"));
assert!(prompt.contains("read_shell_command_output")); assert!(prompt.contains("read_shell_command_output"));
assert!(prompt.contains("interrupt_shell_command")); assert!(prompt.contains("interrupt_shell_command"));
assert!(prompt.contains("next assistant output MUST be a tool call"));
assert!(prompt.contains("alternate screen containing `(END)` is `less`"));
assert!(prompt.contains("`write_to_long_running_shell_command` with input `q` and mode `raw`"));
assert!(prompt.contains("Do not end a still-running monitor turn with prose"));
assert!(prompt.contains("Never try to encode Ctrl+C")); assert!(prompt.contains("Never try to encode Ctrl+C"));
assert!(!prompt.contains("- Use `run_shell_command`")); assert!(!prompt.contains("- Use `run_shell_command`"));
+137 -142
View File
@@ -8,13 +8,14 @@ use aws_sdk_bedrockruntime::types::{
ReasoningContentBlockDelta, StopReason, ReasoningContentBlockDelta, StopReason,
}; };
use futures::stream::BoxStream; use futures::stream::BoxStream;
use galaxy_agent_core::{recall_tool_history, ToolHistoryQuery};
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished; use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent}; use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole}; use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::diagnostic::BedrockDiagnosticLogger; use super::diagnostic::BedrockDiagnosticLogger;
use crate::ai::agent::api::Event; use crate::ai::agent::api::LegacyEvent;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct { fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
@@ -68,7 +69,7 @@ pub fn bedrock_stream_to_response_events(
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>, messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
model_id: String, model_id: String,
tool_result_archive: Vec<ConversationMessage>, tool_result_archive: Vec<ConversationMessage>,
) -> BoxStream<'static, Event> { ) -> BoxStream<'static, LegacyEvent> {
let request_id = Uuid::new_v4().to_string(); let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string(); let conversation_id = Uuid::new_v4().to_string();
@@ -231,13 +232,15 @@ pub fn bedrock_stream_to_response_events(
.unwrap_or(0) as usize; .unwrap_or(0) as usize;
let recall_result = match messages_sent.lock() { let recall_result = match messages_sent.lock() {
Ok(sent) => recall_from_history( Ok(sent) => recall_tool_history(
&sent, &sent,
&tool_result_archive, &tool_result_archive,
search_query, ToolHistoryQuery {
tool_name_filter, search_query,
tool_use_id, tool_name: tool_name_filter,
offset, tool_use_id,
offset_from_end: offset,
},
), ),
Err(_) => "Error: could not access conversation history.".to_string(), Err(_) => "Error: could not access conversation history.".to_string(),
}; };
@@ -319,7 +322,7 @@ pub fn bedrock_stream_to_response_events(
current_tool_name, current_tool_use_id, current_tool_input_json current_tool_name, current_tool_use_id, current_tool_input_json
)); ));
} }
if current_tool_name == "start_agent" { if matches!(current_tool_name.as_str(), "start_agent" | "run_agents") {
has_start_agent_calls = true; has_start_agent_calls = true;
} }
let tool_msg = build_tool_call_message( let tool_msg = build_tool_call_message(
@@ -1241,6 +1244,78 @@ pub fn build_tool_call_message(
api::message::tool_call::EditDocuments { diffs }, api::message::tool_call::EditDocuments { diffs },
)) ))
} }
"run_agents" => {
let agent_run_configs = input
.get("agent_run_configs")
.and_then(|value| value.as_array())
.map(|configs| {
configs
.iter()
.map(|config| api::run_agents::AgentRunConfig {
name: config
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
prompt: config
.get("prompt")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
title: config
.get("title")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
})
.collect()
})
.unwrap_or_default();
let execution_mode = input
.get("execution_mode")
.and_then(run_agents_execution_mode_from_json);
Some(api::message::tool_call::Tool::RunAgents(api::RunAgents {
summary: input
.get("summary")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
base_prompt: input
.get("base_prompt")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
skills: Vec::new(),
model_id: input
.get("model_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
harness: input
.get("harness_type")
.and_then(|value| value.as_str())
.and_then(run_agents_harness_from_str),
agent_run_configs,
execution_mode,
plan_id: input
.get("plan_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
}))
}
"wait_for_events" => {
let idle_timeout_seconds = input
.get("idle_timeout_seconds")
.and_then(|value| value.as_i64())
.and_then(|value| value.try_into().ok())
.unwrap_or(0);
Some(api::message::tool_call::Tool::WaitForEvents(
api::message::tool_call::WaitForEvents {
idle_timeout_seconds,
},
))
}
"start_agent" => { "start_agent" => {
let name = input let name = input
.get("name") .get("name")
@@ -1467,6 +1542,58 @@ pub fn build_tool_call_message(
} }
} }
fn run_agents_execution_mode_from_json(
execution_mode: &serde_json::Value,
) -> Option<api::run_agents::ExecutionMode> {
let mode_type = execution_mode
.get("type")
.and_then(|value| value.as_str())
.or_else(|| execution_mode.as_str());
match mode_type {
Some("remote") => Some(api::run_agents::ExecutionMode::Remote(
api::run_agents::Remote {
environment_id: execution_mode
.get("environment_id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
worker_host: execution_mode
.get("worker_host")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
computer_use_enabled: execution_mode
.get("computer_use_enabled")
.and_then(|value| value.as_bool())
.unwrap_or(false),
},
)),
Some("local") | Some(_) | None => Some(api::run_agents::ExecutionMode::Local(
api::run_agents::Local {},
)),
}
}
fn run_agents_harness_from_str(harness_type: &str) -> Option<api::Harness> {
let variant = match harness_type
.trim()
.to_ascii_lowercase()
.replace('_', "-")
.as_str()
{
"oz" => api::harness::Variant::Oz(api::harness::Oz {}),
"claude" | "claude-code" => api::harness::Variant::ClaudeCode(api::harness::ClaudeCode {}),
"opencode" | "open-code" => api::harness::Variant::OpenCode(api::harness::OpenCode {}),
"gemini" => api::harness::Variant::Gemini(api::harness::Gemini {}),
"codex" => api::harness::Variant::Codex(api::harness::Codex {}),
"" | "unknown" => return None,
_ => return None,
};
Some(api::Harness {
variant: Some(variant),
})
}
/// Built-in tools that Galaxy knows how to execute directly. /// Built-in tools that Galaxy knows how to execute directly.
const KNOWN_TOOLS: &[&str] = &[ const KNOWN_TOOLS: &[&str] = &[
"run_shell_command", "run_shell_command",
@@ -1490,6 +1617,8 @@ const KNOWN_TOOLS: &[&str] = &[
"read_documents", "read_documents",
"create_documents", "create_documents",
"edit_documents", "edit_documents",
"run_agents",
"wait_for_events",
"start_agent", "start_agent",
"ask_user_question", "ask_user_question",
"read_skill", "read_skill",
@@ -1504,137 +1633,3 @@ pub(super) fn is_known_tool(name: &str) -> bool {
fn is_notebook_tool(name: &str) -> bool { fn is_notebook_tool(name: &str) -> bool {
matches!(name, "create_notebook" | "read_notebook" | "edit_notebook") matches!(name, "create_notebook" | "read_notebook" | "edit_notebook")
} }
/// Searches conversation message history for tool call results matching the given criteria.
pub(crate) fn recall_from_history(
messages: &[ConversationMessage],
archive: &[ConversationMessage],
search_query: &str,
tool_name_filter: &str,
tool_use_id: &str,
offset_from_end: usize,
) -> String {
use super::convert::{ContentPart, MessageContent};
struct ToolEntry {
tool_use_id: String,
name: String,
input: String,
result: String,
}
let mut tool_entries: Vec<ToolEntry> = Vec::new();
let mut pending_tool_uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
for msg in messages.iter().chain(archive.iter()) {
match &msg.content {
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((tool_use_id.clone(), name.clone(), input.to_string()));
}
MessageContent::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
MessageContent::MultiPart(parts) => {
for part in parts {
match part {
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((
tool_use_id.clone(),
name.clone(),
input.to_string(),
));
}
ContentPart::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
_ => {}
}
}
}
_ => {}
}
}
let filtered: Vec<&ToolEntry> = tool_entries
.iter()
.filter(|entry| {
if !tool_use_id.is_empty() && entry.tool_use_id != tool_use_id {
return false;
}
if !tool_name_filter.is_empty() && entry.name != tool_name_filter {
return false;
}
if !search_query.is_empty() {
let haystack = format!("{} {} {}", entry.name, entry.input, entry.result);
let query_lower = search_query.to_lowercase();
if !haystack.to_lowercase().contains(&query_lower) {
return false;
}
}
true
})
.collect();
if filtered.is_empty() {
return "No matching tool calls found in conversation history.".to_string();
}
// Get the entry at offset_from_end (0 = most recent)
let idx = if offset_from_end >= filtered.len() {
0
} else {
filtered.len() - 1 - offset_from_end
};
let entry = &filtered[idx];
let result_display = if entry.result.len() > 50000 {
let trunc = entry.result.chars().take(50000).collect::<String>();
format!("{trunc}... [truncated, {} total chars]", entry.result.len())
} else {
entry.result.clone()
};
format!(
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{}",
entry.name, entry.tool_use_id, entry.input, result_display
)
}
@@ -350,6 +350,56 @@ fn development_tool_calls_preserve_focused_reads_file_lifecycle_and_search_filte
assert_eq!(search.path_filters, vec!["app/src/ai", "crates/ai"]); assert_eq!(search.path_filters, vec!["app/src/ai", "crates/ai"]);
} }
#[test]
fn orchestration_tool_calls_build_run_agents_and_wait_for_events() {
let run_tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-run-agents",
"run_agents",
r#"{
"summary": "Investigate in parallel",
"base_prompt": "Shared instructions",
"model_id": "coding-assistant-max",
"harness_type": "codex",
"execution_mode": {
"type": "local"
},
"agent_run_configs": [
{
"name": "code",
"prompt": "Inspect code",
"title": "Code inspection"
}
],
"plan_id": "plan-1"
}"#,
));
let api::message::tool_call::Tool::RunAgents(run_agents) = run_tool else {
panic!("expected run_agents");
};
assert_eq!(run_agents.summary, "Investigate in parallel");
assert_eq!(run_agents.base_prompt, "Shared instructions");
assert_eq!(run_agents.model_id, "coding-assistant-max");
assert!(matches!(
run_agents.execution_mode,
Some(api::run_agents::ExecutionMode::Local(_))
));
assert_eq!(run_agents.agent_run_configs.len(), 1);
assert_eq!(run_agents.agent_run_configs[0].name, "code");
assert_eq!(run_agents.agent_run_configs[0].prompt, "Inspect code");
let wait_tool = tool_from_event(build_tool_call_message(
"task-1",
"tool-wait",
"wait_for_events",
r#"{"idle_timeout_seconds": 120}"#,
));
let api::message::tool_call::Tool::WaitForEvents(wait) = wait_tool else {
panic!("expected wait_for_events");
};
assert_eq!(wait.idle_timeout_seconds, 120);
}
#[test] #[test]
fn test_context_window_for_model_1m_marker() { fn test_context_window_for_model_1m_marker() {
assert_eq!( assert_eq!(
@@ -467,6 +517,8 @@ fn test_cost_zero_for_zero_tokens() {
fn direct_provider_known_tools_exclude_hosted_only_tools() { fn direct_provider_known_tools_exclude_hosted_only_tools() {
assert!(!is_known_tool("send_message_to_agent")); assert!(!is_known_tool("send_message_to_agent"));
assert!(!is_known_tool("suggest_next_prompt")); assert!(!is_known_tool("suggest_next_prompt"));
assert!(is_known_tool("run_agents"));
assert!(is_known_tool("wait_for_events"));
assert!(is_known_tool("recall_tool_history")); assert!(is_known_tool("recall_tool_history"));
assert!(is_known_tool("interrupt_shell_command")); assert!(is_known_tool("interrupt_shell_command"));
} }
+463
View File
@@ -0,0 +1,463 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
use aws_sdk_bedrockruntime::types::{
ContentBlockDelta, ContentBlockStart, ConverseStreamOutput as AwsStreamEvent,
ReasoningContentBlockDelta, StopReason as AwsStopReason,
};
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
use futures::{FutureExt, StreamExt};
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, StopReason, ToolCall, ToolEvent, TurnCommand, TurnControl,
TurnRequest, Usage,
};
use uuid::Uuid;
use super::convert::{build_converse_request, CachingConfig, ConvertedRequest};
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 64_000;
#[derive(Clone)]
pub(crate) struct BedrockAgentRuntime {
client: AwsBedrockClient,
resolved_model: String,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
descriptor: RuntimeDescriptor,
}
impl BedrockAgentRuntime {
pub(crate) fn new(
client: AwsBedrockClient,
configured_model: String,
region: String,
cross_region_inference: bool,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> Result<Self, AgentError> {
let resolved_model = galaxy_agent_rig::resolve_bedrock_model_id(
&configured_model,
&region,
cross_region_inference,
)?;
let descriptor = RuntimeDescriptor {
id: format!("bedrock:{resolved_model}"),
display_name: format!("Bedrock / {resolved_model}"),
kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(),
};
Ok(Self {
client,
resolved_model,
max_output_tokens,
caching_config,
descriptor,
})
}
}
#[async_trait]
impl AgentRuntime for BedrockAgentRuntime {
fn descriptor(&self) -> &RuntimeDescriptor {
&self.descriptor
}
async fn start_turn(
&self,
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let converted =
convert_turn_request(request, self.max_output_tokens, self.caching_config.clone());
let mut request = self
.client
.converse_stream()
.model_id(&self.resolved_model)
.set_system(Some(converted.system))
.set_messages(Some(converted.messages))
.inference_config(converted.inference_config);
if let Some(tool_config) = converted.tool_config {
request = request.tool_config(tool_config);
}
let runtime_request_id = Uuid::new_v4().to_string();
let send_future = request.send().fuse();
let initial_control = control.clone();
let control_future = initial_control.receive().fuse();
futures::pin_mut!(send_future, control_future);
let output = futures::select_biased! {
command = control_future => match command {
Ok(TurnCommand::Cancel) => {
return Ok(stopped_before_stream(runtime_request_id));
}
Ok(TurnCommand::Steer { .. }) | Err(_) => {
send_future.await.map_err(map_bedrock_error)?
}
},
result = send_future => result.map_err(map_bedrock_error)?,
};
Ok(translate_bedrock_stream(
output,
runtime_request_id,
control,
))
}
}
fn convert_turn_request(
request: TurnRequest,
configured_max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> ConvertedRequest {
let max_output_tokens = request
.max_output_tokens
.or(configured_max_output_tokens)
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS)
.min(i32::MAX as u64) as i32;
build_converse_request(
request.messages,
request.system_prompt,
None,
request.tools,
max_output_tokens,
None,
None,
None,
caching_config,
)
}
fn translate_bedrock_stream(
mut output: ConverseStreamOutput,
runtime_request_id: String,
control: TurnControl,
) -> AgentEventStream {
let events = async_stream::stream! {
yield Ok(AgentEvent::TurnStarted {
runtime_request_id,
});
let mut translator = BedrockStreamTranslator::default();
let mut control_open = true;
loop {
let next_event = output.stream.recv().fuse();
let next_command = if control_open {
futures::future::Either::Left(control.receive())
} else {
futures::future::Either::Right(futures::future::pending())
}
.fuse();
futures::pin_mut!(next_event, next_command);
let event = futures::select_biased! {
command = next_command => {
match command {
Ok(TurnCommand::Cancel) => {
yield Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
});
return;
}
Ok(TurnCommand::Steer { .. }) => continue,
Err(_) => {
control_open = false;
continue;
}
}
}
event = next_event => event,
};
match event {
Ok(Some(event)) => match translator.translate(event) {
Ok(events) => {
for event in events {
yield Ok(event);
}
}
Err(error) => {
yield Err(error);
return;
}
},
Ok(None) => match translator.finish() {
Ok(events) => {
for event in events {
yield Ok(event);
}
return;
}
Err(error) => {
yield Err(error);
return;
}
},
Err(error) => {
yield Err(map_bedrock_error(error));
return;
}
}
}
};
Box::pin(events)
}
#[derive(Default)]
struct BedrockStreamTranslator {
content_blocks: BTreeMap<i32, PendingContentBlock>,
stop_reason: Option<StopReason>,
}
impl BedrockStreamTranslator {
fn translate(&mut self, event: AwsStreamEvent) -> Result<Vec<AgentEvent>, AgentError> {
match event {
AwsStreamEvent::MessageStart(_) => Ok(Vec::new()),
AwsStreamEvent::ContentBlockStart(start) => {
let Some(block_start) = start.start() else {
return Ok(Vec::new());
};
let ContentBlockStart::ToolUse(tool) = block_start else {
return Err(protocol_error(
"Bedrock started an unsupported output content block",
));
};
let index = start.content_block_index();
if self
.content_blocks
.insert(
index,
PendingContentBlock::Tool {
id: tool.tool_use_id().to_string(),
name: tool.name().to_string(),
input: String::new(),
},
)
.is_some()
{
return Err(protocol_error(format!(
"Bedrock started content block {index} more than once"
)));
}
Ok(Vec::new())
}
AwsStreamEvent::ContentBlockDelta(delta) => {
let Some(delta_value) = delta.delta() else {
return Err(protocol_error("Bedrock emitted an empty content delta"));
};
let index = delta.content_block_index();
match delta_value {
ContentBlockDelta::Text(text) => {
Ok(vec![AgentEvent::TextDelta { text: text.clone() }])
}
ContentBlockDelta::ReasoningContent(reasoning) => {
let block = self.content_blocks.entry(index).or_insert_with(|| {
PendingContentBlock::Reasoning {
text: String::new(),
signature: None,
}
});
let PendingContentBlock::Reasoning { text, signature } = block else {
return Err(protocol_error(format!(
"Bedrock mixed reasoning and tool data in content block {index}"
)));
};
match reasoning {
ReasoningContentBlockDelta::Text(delta) => {
text.push_str(delta);
Ok(vec![AgentEvent::ReasoningDelta {
text: delta.clone(),
}])
}
ReasoningContentBlockDelta::Signature(delta) => {
signature.get_or_insert_with(String::new).push_str(delta);
Ok(Vec::new())
}
ReasoningContentBlockDelta::RedactedContent(_) => Ok(Vec::new()),
_ => Err(protocol_error("Bedrock emitted an unknown reasoning delta")),
}
}
ContentBlockDelta::ToolUse(tool_delta) => {
let Some(PendingContentBlock::Tool { input, .. }) =
self.content_blocks.get_mut(&index)
else {
return Err(protocol_error(format!(
"Bedrock emitted tool input before starting content block {index}"
)));
};
input.push_str(tool_delta.input());
Ok(Vec::new())
}
ContentBlockDelta::Citation(_) => Ok(Vec::new()),
ContentBlockDelta::Image(_) => {
Err(protocol_error("Bedrock emitted unsupported image output"))
}
ContentBlockDelta::ToolResult(_) => Err(protocol_error(
"Bedrock emitted an unexpected tool-result delta",
)),
_ => Err(protocol_error("Bedrock emitted an unknown content delta")),
}
}
AwsStreamEvent::ContentBlockStop(stop) => {
let index = stop.content_block_index();
let Some(block) = self.content_blocks.remove(&index) else {
return Ok(Vec::new());
};
match block {
PendingContentBlock::Tool { id, name, input } => {
let arguments = serde_json::from_str(&input).map_err(|error| {
protocol_error(format!(
"Bedrock returned invalid JSON for tool '{name}' ({id}): {error}"
))
})?;
Ok(vec![AgentEvent::Tool {
event: ToolEvent::Proposed {
call: ToolCall {
id,
name,
arguments,
},
},
}])
}
PendingContentBlock::Reasoning { text, signature } => {
Ok(vec![AgentEvent::ReasoningCompleted { text, signature }])
}
}
}
AwsStreamEvent::MessageStop(stop) => {
if self.stop_reason.is_some() {
return Err(protocol_error(
"Bedrock emitted more than one message-stop event",
));
}
self.stop_reason = Some(map_stop_reason(stop.stop_reason()));
Ok(Vec::new())
}
AwsStreamEvent::Metadata(metadata) => {
let Some(usage) = metadata.usage() else {
return Ok(Vec::new());
};
Ok(vec![AgentEvent::UsageUpdated {
usage: Usage {
input_tokens: nonnegative_tokens(usage.input_tokens()),
output_tokens: nonnegative_tokens(usage.output_tokens()),
cached_input_tokens: nonnegative_tokens(
usage.cache_read_input_tokens().unwrap_or(0),
),
cache_creation_input_tokens: nonnegative_tokens(
usage.cache_write_input_tokens().unwrap_or(0),
),
},
}])
}
_ => Err(protocol_error("Bedrock emitted an unknown stream event")),
}
}
fn finish(self) -> Result<Vec<AgentEvent>, AgentError> {
if !self.content_blocks.is_empty() {
return Err(protocol_error(
"Bedrock stream ended with incomplete content blocks",
));
}
let reason = self
.stop_reason
.ok_or_else(|| protocol_error("Bedrock stream ended before the message-stop event"))?;
Ok(vec![AgentEvent::TurnStopped { reason }])
}
}
#[derive(Debug)]
enum PendingContentBlock {
Tool {
id: String,
name: String,
input: String,
},
Reasoning {
text: String,
signature: Option<String>,
},
}
fn map_stop_reason(reason: &AwsStopReason) -> StopReason {
match reason {
AwsStopReason::EndTurn | AwsStopReason::StopSequence | AwsStopReason::ToolUse => {
StopReason::Completed
}
AwsStopReason::MaxTokens => StopReason::MaxTokens,
AwsStopReason::ModelContextWindowExceeded => StopReason::ContextWindowExceeded,
AwsStopReason::ContentFiltered | AwsStopReason::GuardrailIntervened => StopReason::Refusal,
AwsStopReason::MalformedModelOutput | AwsStopReason::MalformedToolUse => {
StopReason::Other(reason.as_str().to_string())
}
other => StopReason::Other(other.as_str().to_string()),
}
}
fn nonnegative_tokens(value: i32) -> u64 {
u64::try_from(value).unwrap_or_default()
}
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
Box::pin(futures::stream::iter([
Ok(AgentEvent::TurnStarted { runtime_request_id }),
Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
}),
]))
}
fn map_bedrock_error(error: impl std::fmt::Display + std::fmt::Debug) -> AgentError {
let display = error.to_string();
let debug = format!("{error:?}");
let message = if debug.len() > display.len() {
debug
} else {
display
};
let normalized = message.to_ascii_lowercase();
let kind = if normalized.contains("accessdenied")
|| normalized.contains("access denied")
|| normalized.contains("unauthorized")
|| normalized.contains("credential")
{
AgentErrorKind::Authentication
} else if normalized.contains("throttl") || normalized.contains("rate limit") {
AgentErrorKind::RateLimited
} else if normalized.contains("context window")
|| normalized.contains("too many tokens")
|| normalized.contains("modelcontextwindowexceeded")
{
AgentErrorKind::ContextWindowExceeded
} else if normalized.contains("validation")
|| normalized.contains("resource not found")
|| normalized.contains("resourcenotfound")
{
AgentErrorKind::InvalidRequest
} else if normalized.contains("timeout")
|| normalized.contains("dispatchfailure")
|| normalized.contains("connection")
{
AgentErrorKind::Transport
} else {
AgentErrorKind::Provider
};
let mut error = AgentError::new(kind, message);
error.recoverable = matches!(
kind,
AgentErrorKind::RateLimited | AgentErrorKind::Transport
);
error
}
fn protocol_error(message: impl Into<String>) -> AgentError {
AgentError::new(AgentErrorKind::Protocol, message)
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
+350
View File
@@ -0,0 +1,350 @@
use aws_sdk_bedrockruntime::types::{
CacheTtl, ContentBlock, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart,
ContentBlockStartEvent, ContentBlockStopEvent, ConverseStreamMetadataEvent,
ConverseStreamOutput as AwsStreamEvent, MessageStopEvent, ReasoningContentBlockDelta,
StopReason as AwsStopReason, SystemContentBlock, TokenUsage, Tool, ToolUseBlockDelta,
ToolUseBlockStart,
};
use galaxy_agent_core::{
AgentErrorKind, AgentEvent, ConversationMessage, MessageContent, MessageRole, StopReason,
ToolDefinition, ToolEvent, TurnRequest, Usage,
};
use serde_json::json;
use super::*;
fn turn_request() -> TurnRequest {
let mut request = TurnRequest::new(
"anthropic.claude-test",
vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("first".to_string()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("response".to_string()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("continue".to_string()),
},
],
);
request.system_prompt = Some("system".to_string());
request.tools = vec![ToolDefinition {
name: "read_files".to_string(),
description: "Read files".to_string(),
input_schema: json!({"type": "object"}),
}];
request
}
fn cache_ttls(converted: &ConvertedRequest) -> Vec<Option<CacheTtl>> {
let mut ttls = Vec::new();
for message in &converted.messages {
for block in message.content() {
if let ContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
}
for block in &converted.system {
if let SystemContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
if let Some(tool_config) = &converted.tool_config {
for tool in tool_config.tools() {
if let Tool::CachePoint(point) = tool {
ttls.push(point.ttl().cloned());
}
}
}
ttls
}
#[test]
fn one_turn_transport_preserves_disabled_default_and_one_hour_cache_modes() {
let disabled = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
);
assert!(cache_ttls(&disabled).is_empty());
let default = convert_turn_request(turn_request(), Some(4096), CachingConfig::default());
assert_eq!(cache_ttls(&default), vec![None, None, None]);
let one_hour = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: true,
extended_ttl_requested: true,
},
);
assert_eq!(
cache_ttls(&one_hour),
vec![
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
]
);
}
#[test]
fn one_turn_transport_prefers_request_output_limit() {
let mut request = turn_request();
request.max_output_tokens = Some(8192);
let converted = convert_turn_request(request, Some(4096), CachingConfig::default());
assert_eq!(converted.inference_config.max_tokens(), Some(8192));
}
fn tool_start(index: i32, id: &str, name: &str) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.start(ContentBlockStart::ToolUse(
ToolUseBlockStart::builder()
.tool_use_id(id)
.name(name)
.build()
.unwrap(),
))
.build()
.unwrap(),
)
}
fn ordinary_start(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn content_delta(index: i32, delta: ContentBlockDelta) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockDelta(
ContentBlockDeltaEvent::builder()
.content_block_index(index)
.delta(delta)
.build()
.unwrap(),
)
}
fn content_stop(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStop(
ContentBlockStopEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn message_stop(reason: AwsStopReason) -> AwsStreamEvent {
AwsStreamEvent::MessageStop(
MessageStopEvent::builder()
.stop_reason(reason)
.build()
.unwrap(),
)
}
fn metadata(usage: Usage) -> AwsStreamEvent {
AwsStreamEvent::Metadata(
ConverseStreamMetadataEvent::builder()
.usage(
TokenUsage::builder()
.input_tokens(usage.input_tokens as i32)
.output_tokens(usage.output_tokens as i32)
.total_tokens((usage.input_tokens + usage.output_tokens) as i32)
.cache_read_input_tokens(usage.cached_input_tokens as i32)
.cache_write_input_tokens(usage.cache_creation_input_tokens as i32)
.build()
.unwrap(),
)
.build(),
)
}
#[test]
fn stream_translator_accepts_ordinary_content_block_starts() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator.translate(ordinary_start(0)).unwrap().is_empty());
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::Text("response".to_string()),
))
.unwrap(),
vec![AgentEvent::TextDelta {
text: "response".to_string(),
}]
);
assert!(translator.translate(content_stop(0)).unwrap().is_empty());
}
#[test]
fn stream_translator_correlates_tools_by_content_index() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(2, "call-2", "grep"))
.unwrap();
translator
.translate(tool_start(1, "call-1", "read_files"))
.unwrap();
translator
.translate(content_delta(
1,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"files\":[\"Cargo.toml\"]}")
.build()
.unwrap(),
),
))
.unwrap();
translator
.translate(content_delta(
2,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"query\":\"ProviderRun\"}")
.build()
.unwrap(),
),
))
.unwrap();
let first = translator.translate(content_stop(1)).unwrap();
let second = translator.translate(content_stop(2)).unwrap();
assert!(matches!(
first.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-1"
&& call.name == "read_files"
&& call.arguments == json!({"files": ["Cargo.toml"]})
));
assert!(matches!(
second.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-2"
&& call.name == "grep"
&& call.arguments == json!({"query": "ProviderRun"})
));
}
#[test]
fn stream_translator_defers_stop_until_usage_metadata_arrives() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator
.translate(message_stop(AwsStopReason::EndTurn))
.unwrap()
.is_empty());
let expected_usage = Usage {
input_tokens: 10,
output_tokens: 4,
cached_input_tokens: 7,
cache_creation_input_tokens: 3,
};
assert_eq!(
translator
.translate(metadata(expected_usage.clone()))
.unwrap(),
vec![AgentEvent::UsageUpdated {
usage: expected_usage,
}]
);
assert_eq!(
translator.finish().unwrap(),
vec![AgentEvent::TurnStopped {
reason: StopReason::Completed,
}]
);
}
#[test]
fn stream_translator_preserves_reasoning_text_and_signature() {
let mut translator = BedrockStreamTranslator::default();
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Text(
"inspect".to_string(),
)),
))
.unwrap(),
vec![AgentEvent::ReasoningDelta {
text: "inspect".to_string(),
}]
);
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Signature(
"signature".to_string(),
)),
))
.unwrap();
assert_eq!(
translator.translate(content_stop(0)).unwrap(),
vec![AgentEvent::ReasoningCompleted {
text: "inspect".to_string(),
signature: Some("signature".to_string()),
}]
);
}
#[test]
fn stream_translator_rejects_invalid_tool_json() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(0, "call", "read_files"))
.unwrap();
translator
.translate(content_delta(
0,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("not-json")
.build()
.unwrap(),
),
))
.unwrap();
let error = translator.translate(content_stop(0)).unwrap_err();
assert_eq!(error.kind, AgentErrorKind::Protocol);
}
#[test]
fn bedrock_stop_reasons_map_to_domain_reasons() {
assert_eq!(
map_stop_reason(&AwsStopReason::ToolUse),
StopReason::Completed
);
assert_eq!(
map_stop_reason(&AwsStopReason::MaxTokens),
StopReason::MaxTokens
);
assert_eq!(
map_stop_reason(&AwsStopReason::ModelContextWindowExceeded),
StopReason::ContextWindowExceeded
);
assert_eq!(
map_stop_reason(&AwsStopReason::GuardrailIntervened),
StopReason::Refusal
);
}
-197
View File
@@ -1,197 +0,0 @@
#![allow(dead_code)]
use std::sync::{Arc, Mutex};
use warp_multi_agent_api as api;
use crate::ai::agent::api::ResponseStream;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig, BedrockError};
use crate::ai::bedrock::convert::ConversationMessage;
use crate::ai::bedrock::diagnostic::BedrockDiagnosticLogger;
use crate::ai::bedrock::request_translator;
pub struct TranslatorRequest {
pub config: BedrockClientConfig,
pub model_id: String,
pub root_task_id: Option<String>,
pub bedrock_message_history: Vec<ConversationMessage>,
pub bedrock_tool_result_archive: Vec<ConversationMessage>,
pub bedrock_progressive_summary: Option<String>,
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel.
pub global_rules: Vec<(String, String)>,
}
pub async fn execute(
params: TranslatorRequest,
request: &mut api::Request,
) -> Result<ResponseStream, BedrockError> {
let config = params.config.with_external_fallbacks();
let cross_region_inference = config.cross_region_inference;
let bedrock = BedrockClient::from_config(config).await?;
let task_id = params.root_task_id.unwrap_or_else(|| {
request
.task_context
.as_ref()
.and_then(|tc| tc.tasks.first())
.map(|t| t.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
let needs_create_task = request
.task_context
.as_ref()
.map(|tc| tc.tasks.is_empty())
.unwrap_or(true);
// Use the model from params (selected in UI or defaulted from ANTHROPIC_MODEL)
let mut model_id = params.model_id;
if model_id.is_empty() || model_id == "auto" {
// Fall back to default if nothing is set
model_id = "us.anthropic.claude-opus-4-6[1m]".to_string();
}
log::info!("[bedrock] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}");
let diagnostic_logger =
BedrockDiagnosticLogger::try_new(&model_id, "", "", &task_id).map(Arc::new);
if let Some(ref logger) = diagnostic_logger {
logger.log_protobuf_input(request);
}
request_translator::inject_input_messages_into_task(request);
let new_input_messages = request_translator::extract_new_input_messages(request);
let new_input_count = new_input_messages.len();
let mut messages = Vec::new();
// Prepend progressive summary as the first message pair if present
if let Some(ref summary) = params.bedrock_progressive_summary {
use crate::ai::bedrock::convert::{MessageContent, MessageRole};
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"<conversation-history-summary>\n{}\n</conversation-history-summary>\n\n\
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges.",
summary
)),
});
messages.push(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(
"Understood, I have the prior context. Continuing with the recent conversation."
.to_string(),
),
});
}
let history_len = params.bedrock_message_history.len();
messages.extend(params.bedrock_message_history);
if !new_input_messages.is_empty() {
log::info!(
"[bedrock] Appending {} new input messages to history of {}",
new_input_messages.len(),
history_len
);
messages.extend(new_input_messages);
}
for message in &mut messages {
message.truncate_tool_results_for_provider_request();
}
request_translator::sanitize_messages_for_bedrock(&mut messages);
let system_prompt = request_translator::extract_system_prompt(request, &params.global_rules);
let tools = request_translator::extract_tools(request);
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, progressive_summary={}, tools={}",
messages.len(),
system_prompt.is_some(),
params.bedrock_progressive_summary.is_some(),
tools.len()
);
for (i, msg) in messages.iter().enumerate() {
let content_desc = describe_message_content(&msg.content);
log::info!(
"[bedrock] msg[{}]: role={:?}, content={}",
i,
msg.role,
content_desc
);
}
let user_query_text = request_translator::extract_user_query_text(request);
let stream = bedrock
.converse_stream(
&model_id,
&task_id,
needs_create_task,
messages.clone(),
system_prompt,
None, // progressive summary is in messages array, not system prompt
tools,
64000,
None,
cross_region_inference,
user_query_text,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
params.bedrock_tool_result_archive,
)
.await?;
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
// Only persist the actual conversation history (history + new inputs), not the
// ephemeral prepended summary pair, so we don't duplicate the summary on every
// subsequent write-back. The summary is prepended at request time each turn.
let persistent_count = history_len + new_input_count;
if persistent_count > 0 && messages.len() >= persistent_count {
*sent = messages.split_off(messages.len() - persistent_count);
} else {
*sent = messages;
}
}
Ok(stream)
}
fn describe_message_content(content: &crate::ai::bedrock::convert::MessageContent) -> String {
use crate::ai::bedrock::convert::{ContentPart, MessageContent};
match content {
MessageContent::Text(t) => format!("Text({}chars)", t.len()),
MessageContent::ToolUse {
tool_use_id, name, ..
} => format!("ToolUse(name={}, id={})", name, tool_use_id),
MessageContent::ToolResult {
tool_use_id,
is_error,
..
} => format!("ToolResult(id={}, is_error={})", tool_use_id, is_error),
MessageContent::MultiPart(parts) => {
let part_descs: Vec<String> = parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => format!("Text({})", t.len()),
ContentPart::Image { data, mime_type } => {
format!("Image({mime_type},{}bytes)", data.len())
}
ContentPart::ToolUse {
name, tool_use_id, ..
} => format!("ToolUse({},{})", name, tool_use_id),
ContentPart::ToolResult { tool_use_id, .. } => {
format!("ToolResult({})", tool_use_id)
}
})
.collect();
format!("MultiPart[{}]", part_descs.join(", "))
}
}
}
File diff suppressed because it is too large Load Diff
+286 -62
View File
@@ -24,6 +24,7 @@ pub(super) mod use_computer;
pub(super) mod wait_for_events; pub(super) mod wait_for_events;
use std::any::Any; use std::any::Any;
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
@@ -73,6 +74,7 @@ use serde::{Deserialize, Serialize};
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent}; pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
pub use start_agent::{ pub use start_agent::{
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
StartAgentWaitPolicy,
}; };
pub use suggest_new_conversation::NewConversationDecision; pub use suggest_new_conversation::NewConversationDecision;
use suggest_new_conversation::SuggestNewConversationExecutor; use suggest_new_conversation::SuggestNewConversationExecutor;
@@ -106,6 +108,27 @@ use crate::util::image::{
use crate::util::openable_file_type::is_binary_file; use crate::util::openable_file_type::is_binary_file;
use crate::BlocklistAIHistoryModel; use crate::BlocklistAIHistoryModel;
const CHILD_AGENT_DELEGATION_DENIAL_REASON: &str =
"Child agents are leaf workers and cannot launch additional agents. Complete the assigned task directly or report the blocker to the lead agent.";
const CHILD_AGENT_LEAF_INSTRUCTIONS: &str = r#"You are a leaf worker launched by a lead agent.
- Complete the assigned task directly and stay within its stated scope.
- Do not launch, delegate to, or create additional agents.
- Report blockers and completion to the lead through the available coordination channel."#;
pub(super) fn child_agent_delegation_denial_reason(
conversation_id: AIConversationId,
ctx: &AppContext,
) -> Option<String> {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_child_agent_conversation())
.then(|| CHILD_AGENT_DELEGATION_DENIAL_REASON.to_string())
}
pub(super) fn compose_leaf_agent_prompt(task_prompt: &str) -> String {
format!("{CHILD_AGENT_LEAF_INSTRUCTIONS}\n\nAssigned task:\n{task_prompt}")
}
/// Types of actions that can be executed in parallel. /// Types of actions that can be executed in parallel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ParallelExecutionPolicy { pub(super) enum ParallelExecutionPolicy {
@@ -209,12 +232,6 @@ pub enum NotExecutedReason {
WaitingOnSharer, WaitingOnSharer,
} }
impl NotExecutedReason {
pub fn needs_confirmation(&self) -> bool {
matches!(self, Self::NeedsConfirmation)
}
}
/// Result type for `BlocklistAIActionExecutor::try_to_execute_action`. /// Result type for `BlocklistAIActionExecutor::try_to_execute_action`.
#[derive(Debug)] #[derive(Debug)]
pub(super) enum TryExecuteResult { pub(super) enum TryExecuteResult {
@@ -229,9 +246,36 @@ pub(super) enum TryExecuteResult {
#[derive(Clone)] #[derive(Clone)]
struct AsyncExecutingAction { struct AsyncExecutingAction {
action: AIAgentAction, action: AIAgentAction,
/// The conversation this action belongs to so cancellation and follow-up scheduling remain }
/// scoped even when several conversations have async actions in flight.
conversation_id: AIConversationId, type AsyncExecutingActionKey = (AIConversationId, AIAgentActionId);
#[derive(Default)]
struct AsyncExecutingActions(
std::collections::HashMap<AsyncExecutingActionKey, AsyncExecutingAction>,
);
impl AsyncExecutingActions {
fn insert(&mut self, conversation_id: AIConversationId, running: AsyncExecutingAction) {
self.0
.insert((conversation_id, running.action.id.clone()), running);
}
fn get(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AsyncExecutingAction> {
self.0.get(&(conversation_id, action_id.clone()))
}
fn remove(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<AsyncExecutingAction> {
self.0.remove(&(conversation_id, action_id.clone()))
}
} }
impl AsyncExecutingAction { impl AsyncExecutingAction {
@@ -270,10 +314,9 @@ pub struct BlocklistAIActionExecutor {
send_message_executor: ModelHandle<SendMessageToAgentExecutor>, send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>, ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>, wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
/// The actions currently executing asynchronously, keyed by action ID. /// The actions currently executing asynchronously, scoped by conversation and action ID.
/// We track them per action rather than as a single slot so multiple actions from the same async_executing_actions: AsyncExecutingActions,
/// parallel phase can complete independently. restored_action_ids: HashSet<AsyncExecutingActionKey>,
async_executing_actions: std::collections::HashMap<AIAgentActionId, AsyncExecutingAction>,
/// Reference to the terminal model for checking session sharing state. /// Reference to the terminal model for checking session sharing state.
terminal_model: Arc<FairMutex<TerminalModel>>, terminal_model: Arc<FairMutex<TerminalModel>>,
@@ -334,8 +377,9 @@ impl BlocklistAIActionExecutor {
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone())); let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new()); let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
let start_agent_executor = ctx.add_model(StartAgentExecutor::new); let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
let run_agents_executor = ctx let run_agents_executor = ctx.add_model(|ctx| {
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id)); RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
});
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new()); let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
let ask_user_question_executor = let ask_user_question_executor =
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id)); ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
@@ -360,6 +404,7 @@ impl BlocklistAIActionExecutor {
use_computer_executor, use_computer_executor,
request_computer_use_executor, request_computer_use_executor,
async_executing_actions: Default::default(), async_executing_actions: Default::default(),
restored_action_ids: Default::default(),
terminal_model, terminal_model,
read_skill_executor, read_skill_executor,
fetch_conversation_executor, fetch_conversation_executor,
@@ -371,12 +416,46 @@ impl BlocklistAIActionExecutor {
} }
} }
pub fn async_executing_action(&self, action_id: &AIAgentActionId) -> Option<&AIAgentAction> { pub fn async_executing_action(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<&AIAgentAction> {
self.async_executing_actions self.async_executing_actions
.get(action_id) .get(conversation_id, action_id)
.map(|running| &running.action) .map(|running| &running.action)
} }
pub fn mark_restored_actions(
&mut self,
conversation_id: AIConversationId,
action_ids: &HashSet<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
self.restored_action_ids.extend(
action_ids
.iter()
.cloned()
.map(|action_id| (conversation_id, action_id)),
);
self.run_agents_executor.update(ctx, |executor, _| {
executor.mark_recovery_actions(conversation_id, action_ids);
});
}
pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool {
self.async_executing_actions
.0
.iter()
.any(|((running_conversation_id, _), running)| {
*running_conversation_id == conversation_id
&& matches!(
running.action.action,
AIAgentActionType::AskUserQuestion { .. }
)
})
}
/// Returns the action_id of any running WaitForEvents action for the /// Returns the action_id of any running WaitForEvents action for the
/// given conversation. There is at most one (wait_for_events is /// given conversation. There is at most one (wait_for_events is
/// documented as exclusive within a turn). /// documented as exclusive within a turn).
@@ -384,10 +463,9 @@ impl BlocklistAIActionExecutor {
&self, &self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
) -> Option<AIAgentActionId> { ) -> Option<AIAgentActionId> {
self.async_executing_actions self.async_executing_actions.0.iter().find_map(
.iter() |((running_conversation_id, action_id), running)| {
.find_map(|(action_id, running)| { if *running_conversation_id == conversation_id
if running.conversation_id == conversation_id
&& matches!( && matches!(
running.action.action, running.action.action,
AIAgentActionType::WaitForEvents { .. } AIAgentActionType::WaitForEvents { .. }
@@ -397,7 +475,8 @@ impl BlocklistAIActionExecutor {
} else { } else {
None None
} }
}) },
)
} }
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> { pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
@@ -602,8 +681,8 @@ impl BlocklistAIActionExecutor {
is_user_initiated: bool, is_user_initiated: bool,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> TryExecuteResult { ) -> TryExecuteResult {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}", "try_to_execute_action: action_id={:?}, type={:?}, is_user_initiated={}",
action.id, action.id,
std::mem::discriminant(&action.action), std::mem::discriminant(&action.action),
is_user_initiated is_user_initiated
@@ -611,7 +690,9 @@ impl BlocklistAIActionExecutor {
// We should never actually execute actions in view-only mode. // We should never actually execute actions in view-only mode.
if self.is_shared_session_viewer() { if self.is_shared_session_viewer() {
log::info!("[tool-debug] try_to_execute_action: BLOCKED - shared session viewer mode"); crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: BLOCKED - shared session viewer mode"
);
return TryExecuteResult::NotExecuted { return TryExecuteResult::NotExecuted {
reason: NotExecutedReason::WaitingOnSharer, reason: NotExecutedReason::WaitingOnSharer,
action: Box::new(action), action: Box::new(action),
@@ -624,8 +705,8 @@ impl BlocklistAIActionExecutor {
}; };
let can_auto_execute = self.should_autoexecute(input, ctx); let can_auto_execute = self.should_autoexecute(input, ctx);
let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous(); let is_agent_autonomous = AppExecutionMode::as_ref(ctx).is_autonomous();
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}", "try_to_execute_action: can_auto_execute={}, is_agent_autonomous={}",
can_auto_execute, can_auto_execute,
is_agent_autonomous is_agent_autonomous
); );
@@ -637,8 +718,8 @@ impl BlocklistAIActionExecutor {
|| can_auto_execute || can_auto_execute
|| (is_agent_autonomous && action.action.is_request_command_output())); || (is_agent_autonomous && action.action.is_request_command_output()));
if needs_confirmation { if needs_confirmation {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}", "try_to_execute_action: NEEDS CONFIRMATION - action_id={:?}",
action.id action.id
); );
return TryExecuteResult::NotExecuted { return TryExecuteResult::NotExecuted {
@@ -657,6 +738,7 @@ impl BlocklistAIActionExecutor {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(), action_id: action_id.clone(),
conversation_id,
}); });
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult { result: Arc::new(AIAgentActionResult {
@@ -672,11 +754,13 @@ impl BlocklistAIActionExecutor {
} }
} }
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: EXECUTING action_id={:?}, type={:?}", "try_to_execute_action: EXECUTING action_id={:?}, type={:?}",
action.id, action.id,
std::mem::discriminant(&action.action) std::mem::discriminant(&action.action)
); );
let action_key = (conversation_id, action.id.clone());
let is_restored = self.restored_action_ids.remove(&action_key);
let action_clone = action.clone(); let action_clone = action.clone();
let execution = match &action.action { let execution = match &action.action {
AIAgentActionType::RequestCommandOutput { .. } AIAgentActionType::RequestCommandOutput { .. }
@@ -828,8 +912,8 @@ impl BlocklistAIActionExecutor {
}; };
let action_id = action_clone.id.clone(); let action_id = action_clone.id.clone();
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: execution result type={:?} for action_id={:?}", "try_to_execute_action: execution result type={:?} for action_id={:?}",
match &execution { match &execution {
AnyActionExecution::NotReady => "NotReady", AnyActionExecution::NotReady => "NotReady",
AnyActionExecution::InvalidAction => "InvalidAction", AnyActionExecution::InvalidAction => "InvalidAction",
@@ -840,8 +924,8 @@ impl BlocklistAIActionExecutor {
); );
match execution { match execution {
AnyActionExecution::NotReady => { AnyActionExecution::NotReady => {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}", "try_to_execute_action: NOT READY - action_id={:?}",
action_id action_id
); );
TryExecuteResult::NotExecuted { TryExecuteResult::NotExecuted {
@@ -851,7 +935,7 @@ impl BlocklistAIActionExecutor {
} }
AnyActionExecution::InvalidAction => { AnyActionExecution::InvalidAction => {
log::error!( log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}", "try_to_execute_action: invalid action, action_id={:?}",
action_id action_id
); );
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor."); debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
@@ -865,24 +949,32 @@ impl BlocklistAIActionExecutor {
on_complete, on_complete,
} => { } => {
self.async_executing_actions.insert( self.async_executing_actions.insert(
action_id.clone(), conversation_id,
AsyncExecutingAction { AsyncExecutingAction {
action: action_clone, action: action_clone,
conversation_id,
}, },
); );
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { if !is_restored {
action_id: action_id.clone(), ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
}); action_id: action_id.clone(),
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id); conversation_id,
});
}
crate::ai::tool_diagnostics::tool_debug!(
"try_to_execute_action: spawning ASYNC execution for action_id={:?}",
action_id
);
ctx.spawn(execute_future, move |me, result, ctx| { ctx.spawn(execute_future, move |me, result, ctx| {
let Some(running) = me.async_executing_actions.remove(&action_id) else { let Some(running) = me
log::warn!("[tool-debug] try_to_execute_action: async action completed but not found in executing map, action_id={:?}", action_id); .async_executing_actions
.remove(conversation_id, &action_id)
else {
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
return; return;
}; };
let result = on_complete(result, ctx); let result = on_complete(result, ctx);
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}", "try_to_execute_action: ASYNC action COMPLETED action_id={:?}, result_type={:?}",
action_id, action_id,
std::mem::discriminant(&result) std::mem::discriminant(&result)
); );
@@ -892,16 +984,19 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id, task_id: running.action.task_id,
result, result,
}), }),
conversation_id: running.conversation_id, conversation_id,
cancellation_reason: None, cancellation_reason: None,
}); });
}); });
TryExecuteResult::ExecutedAsync TryExecuteResult::ExecutedAsync
} }
AnyActionExecution::Sync(action_result) => { AnyActionExecution::Sync(action_result) => {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction { if !is_restored {
action_id: action_id.clone(), ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
}); action_id: action_id.clone(),
conversation_id,
});
}
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction { ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult { result: Arc::new(AIAgentActionResult {
id: action_id, id: action_id,
@@ -933,6 +1028,7 @@ impl BlocklistAIActionExecutor {
pub fn cancel_running_async_action( pub fn cancel_running_async_action(
&mut self, &mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
reason: Option<CancellationReason>, reason: Option<CancellationReason>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
@@ -941,13 +1037,42 @@ impl BlocklistAIActionExecutor {
if self.is_shared_session_viewer() { if self.is_shared_session_viewer() {
return; return;
} }
if let Some(running) = self.async_executing_actions.remove(action_id) { if self
.async_executing_actions
.get(conversation_id, action_id)
.is_some_and(|running| {
matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
})
{
let termination_requested = self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(action_id, ctx)
});
if termination_requested {
// Keep the action in flight until block completion proves the process stopped.
// Its normal async completion will report the actual terminal exit status.
return;
}
}
if let Some(running) = self
.async_executing_actions
.remove(conversation_id, action_id)
{
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action); let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}", "Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}"
std::backtrace::Backtrace::force_capture()
); );
if running.is_shell_command_action() { if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Running action cancellation backtrace:\n{backtrace}");
}
if running.is_shell_command_action()
&& !matches!(
running.action.action,
AIAgentActionType::RequestCommandOutput { .. }
)
{
self.shell_command_executor.update(ctx, |executor, ctx| { self.shell_command_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx); executor.cancel_execution(&running.action.id, ctx);
}); });
@@ -957,7 +1082,11 @@ impl BlocklistAIActionExecutor {
}); });
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) { } else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
self.run_agents_executor.update(ctx, |executor, ctx| { self.run_agents_executor.update(ctx, |executor, ctx| {
executor.cancel_execution(&running.action.id, ctx); executor.cancel_execution(conversation_id, &running.action.id, ctx);
});
} else if matches!(running.action.action, AIAgentActionType::StartAgent { .. }) {
self.start_agent_executor.update(ctx, |executor, _| {
executor.cancel_execution(conversation_id, &running.action.id);
}); });
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } = } else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
&running.action.action &running.action.action
@@ -975,7 +1104,7 @@ impl BlocklistAIActionExecutor {
task_id: running.action.task_id, task_id: running.action.task_id,
result: running.action.action.cancelled_result(), result: running.action.action.cancelled_result(),
}), }),
conversation_id: running.conversation_id, conversation_id,
cancellation_reason: reason, cancellation_reason: reason,
}); });
} }
@@ -989,18 +1118,23 @@ impl BlocklistAIActionExecutor {
) { ) {
let action_ids = self let action_ids = self
.async_executing_actions .async_executing_actions
.0
.iter() .iter()
.filter_map(|(action_id, running)| { .filter_map(|((running_conversation_id, action_id), _)| {
(running.conversation_id == conversation_id).then_some(action_id.clone()) (*running_conversation_id == conversation_id).then_some(action_id.clone())
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for action_id in action_ids { for action_id in action_ids {
self.cancel_running_async_action(&action_id, reason, ctx); self.cancel_running_async_action(conversation_id, &action_id, reason, ctx);
} }
} }
fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool { fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool {
if cfg!(feature = "bedrock_smoke_test") { if self
.restored_action_ids
.contains(&(input.conversation_id, input.action.id.clone()))
|| cfg!(feature = "bedrock_smoke_test")
{
return true; return true;
} }
match input.action.action { match input.action.action {
@@ -1109,9 +1243,10 @@ impl Entity for BlocklistAIActionExecutor {
} }
pub enum BlocklistAIActionExecutorEvent { pub enum BlocklistAIActionExecutorEvent {
/// Emitted when an action is execution starts. /// Emitted when an action begins execution.
ExecutingAction { ExecutingAction {
action_id: AIAgentActionId, action_id: AIAgentActionId,
conversation_id: AIConversationId,
}, },
/// Emitted when an action has finished. /// Emitted when an action has finished.
@@ -1442,6 +1577,95 @@ async fn read_file_as_binary(file_path: &std::path::Path) -> Result<Vec<u8>, Fil
async_fs::read(file_path).await.map_err(FileLoadError::from) async_fs::read(file_path).await.map_err(FileLoadError::from)
} }
#[cfg(test)]
mod async_executing_action_tests {
use super::*;
use crate::ai::agent::task::TaskId;
fn action(id: &str, task_id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_owned()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new(task_id.to_owned()),
requires_result: true,
tool_name: Some("init_project".to_owned()),
}
}
#[test]
fn duplicate_action_ids_can_execute_concurrently_in_different_conversations() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
assert_eq!(running.0.len(), 2);
assert_eq!(
running
.get(first_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("first-task".to_owned())
);
assert_eq!(
running
.get(second_conversation, &duplicate_id)
.unwrap()
.action
.task_id,
TaskId::new("second-task".to_owned())
);
}
#[test]
fn duplicate_action_completion_and_cancellation_remove_only_the_matching_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let mut running = AsyncExecutingActions::default();
running.insert(
first_conversation,
AsyncExecutingAction {
action: action("duplicate", "first-task"),
},
);
running.insert(
second_conversation,
AsyncExecutingAction {
action: action("duplicate", "second-task"),
},
);
let completed = running.remove(first_conversation, &duplicate_id).unwrap();
assert_eq!(
completed.action.task_id,
TaskId::new("first-task".to_owned())
);
assert!(running.get(second_conversation, &duplicate_id).is_some());
let cancelled = running.remove(second_conversation, &duplicate_id).unwrap();
assert_eq!(
cancelled.action.task_id,
TaskId::new("second-task".to_owned())
);
assert!(running.0.is_empty());
}
}
#[cfg(all(test, feature = "local_fs"))] #[cfg(all(test, feature = "local_fs"))]
#[path = "execute_tests.rs"] #[path = "execute_tests.rs"]
mod tests; mod tests;
@@ -83,6 +83,9 @@ fn initialize_ask_user_question_test(
app.add_singleton_model(TeamTesterStatus::mock); app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock); app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock); app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default()); app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock); app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| { let profiles = app.add_singleton_model(|ctx| {
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
{ {
log::info!("[tool-debug] CallMCPToolExecutor::execute called"); crate::ai::tool_diagnostics::tool_debug!("CallMCPToolExecutor::execute called");
let server_output_id = get_server_output_id(input.conversation_id, ctx); let server_output_id = get_server_output_id(input.conversation_id, ctx);
let AIAgentAction { let AIAgentAction {
action: action:
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
.. ..
} = input.action } = input.action
else { else {
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!"); log::error!("CallMCPToolExecutor::execute: action type mismatch");
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
let name_owned = name.to_owned(); let name_owned = name.to_owned();
let name_clone = name_owned.clone(); let name_clone = name_owned.clone();
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}", "CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
name, name,
server_id, server_id,
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string()) serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
); );
let serde_json::Value::Object(mut arguments) = input.clone() else { let serde_json::Value::Object(mut arguments) = input.clone() else {
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!"); log::error!("CallMCPToolExecutor: input is not an object");
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool( return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()), CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
)); ));
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
let Some(reconnecting_peer) = templatable_peer else { let Some(reconnecting_peer) = templatable_peer else {
log::error!( log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND", "CallMCPToolExecutor: MCP server for tool '{}' not found",
name_owned name_owned
); );
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool( return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()), CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
)); ));
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'", "CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned name_owned
); );
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
tool_name: String, tool_name: String,
ctx: &galaxyui::AppContext, ctx: &galaxyui::AppContext,
) -> AIAgentActionResultType { ) -> AIAgentActionResultType {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}", "handle_call_tool_result: tool_name={}, is_ok={}",
tool_name, tool_name,
res.is_ok() res.is_ok()
); );
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}", "FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns, patterns,
path path
); );
@@ -237,8 +237,8 @@ impl GrepExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}", "GrepExecutor::execute: queries={:?}, path={:?}",
queries, queries,
path path
); );
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] ReadFilesExecutor::execute: {} files requested", "ReadFilesExecutor::execute: {} files requested",
locations.len() locations.len()
); );
@@ -42,10 +42,34 @@ use crate::terminal::model::session::SessionType;
use crate::{safe_warn, BlocklistAIHistoryModel}; use crate::{safe_warn, BlocklistAIHistoryModel};
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10; const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
type AppliedDiffs = (Vec<FileDiff>, DiffSessionType);
#[derive(Default)]
struct PendingAppliedDiffs {
by_action: HashMap<AIAgentActionId, AppliedDiffs>,
}
impl PendingAppliedDiffs {
fn buffer(
&mut self,
action_id: AIAgentActionId,
diffs: Vec<FileDiff>,
diff_session_type: DiffSessionType,
) {
self.by_action.insert(action_id, (diffs, diff_session_type));
}
fn take(&mut self, action_id: &AIAgentActionId) -> Option<AppliedDiffs> {
self.by_action.remove(action_id)
}
}
pub struct RequestFileEditsExecutor { pub struct RequestFileEditsExecutor {
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
apply_diff_model: ModelHandle<ApplyDiffModel>, apply_diff_model: ModelHandle<ApplyDiffModel>,
diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>, diff_views: HashMap<AIAgentActionId, ViewHandle<CodeDiffView>>,
/// Successfully applied diffs that completed before their view was registered.
pending_applied_diffs: PendingAppliedDiffs,
/// Set of action IDs where diff application failed. /// Set of action IDs where diff application failed.
diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>, diff_application_failures: HashMap<AIAgentActionId, Vec1<DiffApplicationError>>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
@@ -62,6 +86,7 @@ impl RequestFileEditsExecutor {
active_session, active_session,
apply_diff_model, apply_diff_model,
diff_views: HashMap::new(), diff_views: HashMap::new(),
pending_applied_diffs: PendingAppliedDiffs::default(),
diff_application_failures: HashMap::new(), diff_application_failures: HashMap::new(),
terminal_view_id, terminal_view_id,
} }
@@ -117,15 +142,18 @@ impl RequestFileEditsExecutor {
.is_allowed() .is_allowed()
} }
/// Registers a diff view to handle a RequestFileEdits action. /// Registers a diff view to handle a RequestFileEdits action and applies any diffs that
/// Note this MUST be called before `execute` or `preprocess_action` is invoked in /// finished preprocessing before the UI observed the action.
/// order for the necessary state to be set to handle the action.
pub fn register_requested_edits( pub fn register_requested_edits(
&mut self, &mut self,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
view: &ViewHandle<CodeDiffView>, view: &ViewHandle<CodeDiffView>,
ctx: &mut ModelContext<Self>,
) { ) {
self.diff_views.insert(action_id.clone(), view.clone()); self.diff_views.insert(action_id.clone(), view.clone());
if let Some((diffs, diff_session_type)) = self.pending_applied_diffs.take(action_id) {
Self::apply_diffs_to_view(view, diffs, diff_session_type, ctx);
}
} }
pub(super) fn execute( pub(super) fn execute(
@@ -145,14 +173,14 @@ impl RequestFileEditsExecutor {
else { else {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}", "RequestFileEditsExecutor::execute: action_id={:?}",
id id
); );
let Some(diff_view) = self.diff_views.get(id) else { let Some(diff_view) = self.diff_views.get(id) else {
log::warn!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}", "RequestFileEditsExecutor: no diff view found for action_id={:?}",
id id
); );
return ActionExecution::NotReady; return ActionExecution::NotReady;
@@ -322,23 +350,43 @@ impl RequestFileEditsExecutor {
tx: oneshot::Sender<()>, tx: oneshot::Sender<()>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
tx.send(()).ok(); match applied_diffs {
Ok(applied_diffs) if !applied_diffs.is_empty() => {
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let diffs = applied_diffs
.into_iter()
.map(|diff| {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
FileDiff::new(diff.original_content, path, diff.diff_type)
})
.collect();
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
let Some(diff_view) = self.diff_views.get(&id) else { if let Some(diff_view) = self.diff_views.get(&id).cloned() {
log::warn!( Self::apply_diffs_to_view(&diff_view, diffs, diff_session_type, ctx);
"Tried to apply diffs for a RequestFileEdits action without a corresponding diff view" } else {
); self.pending_applied_diffs
return; .buffer(id, diffs, diff_session_type);
}; }
}
let applied_diffs = match applied_diffs {
Ok(diffs) if !diffs.is_empty() => diffs,
Ok(_) => { Ok(_) => {
// We didn't generate any diffs--consider this a failure.
log::warn!("No diffs generated"); log::warn!("No diffs generated");
self.diff_application_failures self.diff_application_failures
.insert(id, vec1![DiffApplicationError::EmptyDiff]); .insert(id, vec1![DiffApplicationError::EmptyDiff]);
return;
} }
Err(err) => { Err(err) => {
safe_warn!( safe_warn!(
@@ -346,38 +394,18 @@ impl RequestFileEditsExecutor {
full: ("Failed to generate diffs {err:?}") full: ("Failed to generate diffs {err:?}")
); );
self.diff_application_failures.insert(id, err); self.diff_application_failures.insert(id, err);
return;
} }
};
let current_working_directory = self
.active_session
.as_ref(ctx)
.current_working_directory()
.cloned();
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let mut diffs = Vec::with_capacity(applied_diffs.len());
for diff in applied_diffs {
let path = host_native_absolute_path(
diff.file_name.as_str(),
&shell_launch_data,
&current_working_directory,
);
let file_diff = FileDiff::new(diff.original_content, path, diff.diff_type);
diffs.push(file_diff);
} }
// Set the session type on the diff view so save/delete/create routes tx.send(()).ok();
// through the correct FileModel backend. }
let diff_session_type = match self.active_session.as_ref(ctx).session_type(ctx) {
Some(SessionType::WarpifiedRemote {
host_id: Some(host_id),
}) => DiffSessionType::Remote(host_id.clone()),
_ => DiffSessionType::Local,
};
fn apply_diffs_to_view(
diff_view: &ViewHandle<CodeDiffView>,
diffs: Vec<FileDiff>,
diff_session_type: DiffSessionType,
ctx: &mut ModelContext<Self>,
) {
diff_view.update(ctx, |diff_view, ctx| { diff_view.update(ctx, |diff_view, ctx| {
diff_view.set_diff_session_type(diff_session_type); diff_view.set_diff_session_type(diff_session_type);
diff_view.set_candidate_diffs(diffs, ctx); diff_view.set_candidate_diffs(diffs, ctx);
@@ -2,8 +2,36 @@ use std::collections::HashMap;
use ai::agent::action_result::AnyFileContent; use ai::agent::action_result::AnyFileContent;
use ai::agent::FileLocations; use ai::agent::FileLocations;
use ai::diff_validation::DiffType;
use super::updated_file_contexts_from_editor_buffers; use super::{
updated_file_contexts_from_editor_buffers, AIAgentActionId, DiffSessionType, FileDiff,
PendingAppliedDiffs,
};
#[test]
fn applied_diffs_survive_until_delayed_view_registration() {
let action_id = AIAgentActionId::from("file-edit".to_string());
let mut pending = PendingAppliedDiffs::default();
pending.buffer(
action_id.clone(),
vec![FileDiff::new(
"before".to_string(),
"/workspace/src/main.rs".to_string(),
DiffType::update(vec![], None),
)],
DiffSessionType::Local,
);
let (diffs, session_type) = pending
.take(&action_id)
.expect("buffered diffs should remain available for registration");
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].base.content, "before");
assert_eq!(diffs[0].base.file_path, "/workspace/src/main.rs");
assert!(matches!(session_type, DiffSessionType::Local));
assert!(pending.take(&action_id).is_none());
}
#[test] #[test]
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() { fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
@@ -2,7 +2,7 @@
//! //!
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`] //! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
//! and aggregates the outcomes into a single `RunAgentsResult`. //! and aggregates the outcomes into a single `RunAgentsResult`.
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::time::Duration; use std::time::Duration;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
@@ -12,15 +12,21 @@ use ai::agent::action_result::{
}; };
use ai::agent::orchestration_config::OrchestrationConfig; use ai::agent::orchestration_config::OrchestrationConfig;
use ai::skills::SkillReference; use ai::skills::SkillReference;
use futures::future::BoxFuture; use futures::future::{join_all, BoxFuture};
use futures::FutureExt; use futures::FutureExt;
use galaxy_core::execution_mode::AppExecutionMode; use galaxy_core::execution_mode::AppExecutionMode;
use settings::Setting; use settings::Setting;
use warp_cli::agent::Harness; use warp_cli::agent::Harness;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{StartAgentExecutor, StartAgentOutcome}; use super::start_agent::{
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput}; StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
StartAgentRequestId, StartAgentWaitPolicy,
};
use super::{
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput, AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
@@ -34,6 +40,8 @@ use crate::ai::document::plan_publication::{
prepare_plan_publications, wait_for_plan_publications, prepare_plan_publications, wait_for_plan_publications,
}; };
use crate::ai::local_harness_setup::local_harness_product_disabled_message; use crate::ai::local_harness_setup::local_harness_product_disabled_message;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
/// Per-child spawn timeout. If a child agent doesn't report back within /// Per-child spawn timeout. If a child agent doesn't report back within
/// this window (e.g. binary not found, server error), the slot is failed /// this window (e.g. binary not found, server error), the slot is failed
@@ -60,7 +68,8 @@ struct ExistingLaunchedAgent {
} }
pub struct RunAgentsExecutor { pub struct RunAgentsExecutor {
pending: HashMap<AIAgentActionId, PendingRunAgents>, pending: HashMap<(AIConversationId, AIAgentActionId), PendingRunAgents>,
recovery_action_ids: HashSet<(AIConversationId, AIAgentActionId)>,
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>, launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
start_agent_executor: ModelHandle<StartAgentExecutor>, start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
@@ -69,12 +78,20 @@ pub struct RunAgentsExecutor {
/// Lifecycle events for in-flight dispatches. /// Lifecycle events for in-flight dispatches.
pub enum RunAgentsExecutorEvent { pub enum RunAgentsExecutorEvent {
SpawningStarted { SpawningStarted {
conversation_id: AIConversationId,
action_id: AIAgentActionId, action_id: AIAgentActionId,
snapshot: RunAgentsSpawningSnapshot, snapshot: RunAgentsSpawningSnapshot,
}, },
SpawningFinished { SpawningFinished {
conversation_id: AIConversationId,
action_id: AIAgentActionId, action_id: AIAgentActionId,
}, },
ChildConversationCreated {
action_id: AIAgentActionId,
agent_name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
} }
impl Entity for RunAgentsExecutor { impl Entity for RunAgentsExecutor {
@@ -85,31 +102,77 @@ impl RunAgentsExecutor {
pub fn new( pub fn new(
start_agent_executor: ModelHandle<StartAgentExecutor>, start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Self { ) -> Self {
ctx.subscribe_to_model(&start_agent_executor, |_, _, event, ctx| {
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id,
agent_name,
parent_conversation_id,
child_conversation_id,
} = event
{
ctx.emit(RunAgentsExecutorEvent::ChildConversationCreated {
action_id: action_id.clone(),
agent_name: agent_name.clone(),
parent_conversation_id: *parent_conversation_id,
child_conversation_id: *child_conversation_id,
});
}
});
Self { Self {
pending: HashMap::new(), pending: HashMap::new(),
recovery_action_ids: HashSet::new(),
launched_agents: HashMap::new(), launched_agents: HashMap::new(),
start_agent_executor, start_agent_executor,
terminal_view_id, terminal_view_id,
} }
} }
pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool { pub fn is_pending(
self.pending.contains_key(action_id) &self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> bool {
self.pending
.contains_key(&(conversation_id, action_id.clone()))
} }
/// Cancels a pending run so publication completion cannot fan out children. pub fn mark_recovery_actions(
&mut self,
conversation_id: AIConversationId,
action_ids: &HashSet<AIAgentActionId>,
) {
self.recovery_action_ids.extend(
action_ids
.iter()
.cloned()
.map(|action_id| (conversation_id, action_id)),
);
}
pub(crate) fn terminal_view_id(&self) -> EntityId {
self.terminal_view_id
}
/// Cancels the parent tool wait without cancelling independently-running children.
pub(super) fn cancel_execution( pub(super) fn cancel_execution(
&mut self, &mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
if matches!( let action_key = (conversation_id, action_id.clone());
self.pending.get(action_id), self.recovery_action_ids.remove(&action_key);
Some(PendingRunAgents::Publishing) let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
) { executor.cancel_dispatches_for_action(conversation_id, action_id)
self.pending.remove(action_id); });
crate::ai::tool_diagnostics::tool_debug!(
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
);
if self.pending.remove(&action_key).is_some() {
ctx.emit(RunAgentsExecutorEvent::SpawningFinished { ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
conversation_id,
action_id: action_id.clone(), action_id: action_id.clone(),
}); });
} }
@@ -122,6 +185,22 @@ impl RunAgentsExecutor {
) { ) {
for agent in agents { for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else { let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
self.launched_agents
.entry(conversation_id)
.or_default()
.insert(
normalized_name,
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
},
);
continue; continue;
}; };
let Some(normalized_name) = normalize_agent_name(&agent.name) else { let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -164,14 +243,39 @@ impl RunAgentsExecutor {
) -> async_channel::Receiver<RunAgentsResult> { ) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1); let (sender, receiver) = async_channel::bounded(1);
if self.pending.contains_key(&action_id) { let action_key = (parent_conversation_id, action_id.clone());
if self.pending.contains_key(&action_key) {
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting"); log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents dispatch rejected",
serde_json::json!({
"event": "run_agents_dispatch_rejected",
"reason": "reentered_pending_action",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
}),
);
let _ = sender.try_send(RunAgentsResult::Cancelled); let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver; return receiver;
} }
if let Err(error) = validate_request(&request) { if let Err(error) = validate_request(&request) {
log::warn!("RunAgentsExecutor: validation failure: {error}"); log::warn!("RunAgentsExecutor: validation failure: {error}");
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents validation failed",
serde_json::json!({
"event": "run_agents_validation_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"error": remote_logging::sanitize_error(&error),
}),
);
let _ = sender.try_send(RunAgentsResult::Failure { error }); let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver; return receiver;
} }
@@ -181,8 +285,22 @@ impl RunAgentsExecutor {
agent_count: request.agent_run_configs.len(), agent_count: request.agent_run_configs.len(),
}; };
self.pending self.pending
.insert(action_id.clone(), PendingRunAgents::Publishing); .insert(action_key, PendingRunAgents::Publishing);
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents plan publication wait started",
serde_json::json!({
"event": "run_agents_plan_publication_wait_started",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_count": snapshot.agent_count,
"plan_id_present": !request.plan_id.trim().is_empty(),
}),
);
ctx.emit(RunAgentsExecutorEvent::SpawningStarted { ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
conversation_id: parent_conversation_id,
action_id: action_id.clone(), action_id: action_id.clone(),
snapshot, snapshot,
}); });
@@ -197,13 +315,14 @@ impl RunAgentsExecutor {
request request
}, },
move |me, request, ctx| { move |me, request, ctx| {
if !me.is_pending(&action_id_for_wait) { if !me.is_pending(parent_conversation_id, &action_id_for_wait) {
return; return;
} }
me.dispatch_children_for_prepared_request( me.dispatch_children_for_prepared_request(
action_id_for_wait.clone(), action_id_for_wait.clone(),
request, request,
parent_conversation_id, parent_conversation_id,
HashMap::new(),
sender, sender,
ctx, ctx,
) )
@@ -213,16 +332,56 @@ impl RunAgentsExecutor {
receiver receiver
} }
fn dispatch_recovered_run_agents(
&mut self,
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
recovery_children: HashMap<String, AIConversationId>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
if self.is_pending(parent_conversation_id, &action_id) {
let _ = sender.try_send(RunAgentsResult::Cancelled);
return receiver;
}
if let Err(error) = validate_request(&request) {
let _ = sender.try_send(RunAgentsResult::Failure { error });
return receiver;
}
let snapshot = RunAgentsSpawningSnapshot {
agent_count: request.agent_run_configs.len(),
};
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
conversation_id: parent_conversation_id,
action_id: action_id.clone(),
snapshot,
});
self.dispatch_children_for_prepared_request(
action_id,
request,
parent_conversation_id,
recovery_children,
sender,
ctx,
);
receiver
}
fn dispatch_children_for_prepared_request( fn dispatch_children_for_prepared_request(
&mut self, &mut self,
action_id: AIAgentActionId, action_id: AIAgentActionId,
request: RunAgentsRequest, request: RunAgentsRequest,
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
mut recovery_children: HashMap<String, AIConversationId>,
sender: async_channel::Sender<RunAgentsResult>, sender: async_channel::Sender<RunAgentsResult>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
self.pending self.pending.insert(
.insert(action_id.clone(), PendingRunAgents::Spawning); (parent_conversation_id, action_id.clone()),
PendingRunAgents::Spawning,
);
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx) let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&parent_conversation_id) .conversation(&parent_conversation_id)
.and_then(|c| c.run_id()); .and_then(|c| c.run_id());
@@ -238,8 +397,46 @@ impl RunAgentsExecutor {
.. ..
} = request; } = request;
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents child dispatch started",
serde_json::json!({
"event": "run_agents_child_dispatch_started",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_count": agent_run_configs.len(),
"execution_mode": run_agents_execution_mode_label(&run_execution_mode),
"harness_type": harness_type.as_str(),
"model_id_present": !model_id.trim().is_empty(),
"parent_run_id_present": parent_run_id.is_some(),
}),
);
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len()); let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
let wait_policy = match &run_execution_mode {
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
};
for cfg in &agent_run_configs { for cfg in &agent_run_configs {
let normalized_name = normalize_agent_name(&cfg.name)
.expect("validated RunAgents requests have non-empty agent names");
if let Some(child_conversation_id) = recovery_children.remove(&normalized_name) {
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.reattach(
action_id.clone(),
cfg.name.clone(),
parent_conversation_id,
child_conversation_id,
wait_policy,
exec_ctx,
)
});
slots.push(ChildSlot::Pending(dispatch));
continue;
}
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt); let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
let mode = match run_agents_to_start_agent_mode( let mode = match run_agents_to_start_agent_mode(
&run_execution_mode, &run_execution_mode,
@@ -251,6 +448,19 @@ impl RunAgentsExecutor {
) { ) {
Ok(mode) => mode, Ok(mode) => mode,
Err(err) => { Err(err) => {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents child dispatch failed before launch",
serde_json::json!({
"event": "run_agents_child_dispatch_prelaunch_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"error": remote_logging::sanitize_error(&err),
}),
);
slots.push(ChildSlot::Failed(err)); slots.push(ChildSlot::Failed(err));
continue; continue;
} }
@@ -258,13 +468,40 @@ impl RunAgentsExecutor {
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. }) if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
&& parent_run_id.is_none() && parent_run_id.is_none()
{ {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents remote child dispatch missing parent run_id",
serde_json::json!({
"event": "run_agents_child_dispatch_prelaunch_failed",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"error": "Remote child agents require the parent run_id to be available.",
}),
);
slots.push(ChildSlot::Failed( slots.push(ChildSlot::Failed(
"Remote child agents require the parent run_id to be available.".to_string(), "Remote child agents require the parent run_id to be available.".to_string(),
)); ));
continue; continue;
} }
let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| { #[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents child dispatch queued",
serde_json::json!({
"event": "run_agents_child_dispatch_queued",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"agent_name": cfg.name.as_str(),
"execution_mode": start_agent_execution_mode_label(&mode),
}),
);
let dispatch = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
executor.dispatch( executor.dispatch(
action_id.clone(),
cfg.name.clone(), cfg.name.clone(),
prompt, prompt,
mode, mode,
@@ -274,7 +511,7 @@ impl RunAgentsExecutor {
exec_ctx, exec_ctx,
) )
}); });
slots.push(ChildSlot::Pending(recv)); slots.push(ChildSlot::Pending(dispatch));
} }
let agent_run_configs_for_result = agent_run_configs.clone(); let agent_run_configs_for_result = agent_run_configs.clone();
@@ -283,65 +520,95 @@ impl RunAgentsExecutor {
let run_harness_type = harness_type.clone(); let run_harness_type = harness_type.clone();
let run_execution_mode_for_aggr = run_execution_mode.clone(); let run_execution_mode_for_aggr = run_execution_mode.clone();
let parent_conversation_id_for_result = parent_conversation_id; let parent_conversation_id_for_result = parent_conversation_id;
#[cfg(not(target_family = "wasm"))]
let action_id_for_async_log = action_id.clone();
#[cfg(not(target_family = "wasm"))]
let parent_conversation_id_for_async_log = parent_conversation_id;
#[cfg(not(target_family = "wasm"))]
let agent_names_for_async_log = agent_run_configs
.iter()
.map(|cfg| cfg.name.clone())
.collect::<Vec<_>>();
ctx.spawn( ctx.spawn(
async move { async move {
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len()); let resolved_slots = join_all(slots.into_iter().map(resolve_child_slot)).await;
for slot in slots { #[cfg(not(target_family = "wasm"))]
let kind = match slot { for (slot_index, resolved) in resolved_slots.iter().enumerate() {
ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error }, log::info!(
ChildSlot::Pending(recv) => { "RunAgents child launch outcome action_id={} parent_conversation_id={} \
let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT); agent_name={} slot_index={} outcome={}",
match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout)) action_id_for_async_log,
.await parent_conversation_id_for_async_log,
{ agent_names_for_async_log
futures::future::Either::Left(( .get(slot_index)
Ok(StartAgentOutcome::Started { agent_id }), .map(String::as_str)
_, .unwrap_or("<unknown>"),
)) => RunAgentsAgentOutcomeKind::Launched { agent_id }, slot_index,
futures::future::Either::Left(( run_agents_agent_outcome_kind_label(&resolved.outcome)
Ok(StartAgentOutcome::Completed { agent_id, .. }), );
_,
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
futures::future::Either::Left((
Ok(StartAgentOutcome::Error(error)),
_,
)) => RunAgentsAgentOutcomeKind::Failed { error },
futures::future::Either::Left((Err(_), _)) => {
RunAgentsAgentOutcomeKind::Failed {
error: "Cancelled before launch".to_string(),
}
}
futures::future::Either::Right((_, _)) => {
log::warn!(
"Agent spawn timed out after {} seconds",
SPAWN_TIMEOUT.as_secs()
);
RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
SPAWN_TIMEOUT.as_secs()
),
}
}
}
}
};
outcomes.push(kind);
} }
outcomes resolved_slots
}, },
move |me, outcomes, ctx| { move |me, resolved_slots, ctx| {
if !me.is_pending(parent_conversation_id_for_result, &action_id_for_aggr) {
return;
}
let timed_out_request_ids = resolved_slots
.iter()
.filter_map(|resolved| resolved.timed_out_request_id)
.collect::<Vec<_>>();
if !timed_out_request_ids.is_empty() {
me.start_agent_executor.update(ctx, |executor, _| {
for request_id in timed_out_request_ids {
executor.detach_dispatch(request_id);
}
});
}
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
.iter() .iter()
.zip(outcomes) .zip(resolved_slots)
.map(|(cfg, kind)| RunAgentsAgentOutcome { .map(|(cfg, resolved)| RunAgentsAgentOutcome {
name: cfg.name.clone(), name: cfg.name.clone(),
kind, kind: resolved.outcome,
}) })
.collect(); .collect();
me.record_launched_agents(parent_conversation_id_for_result, &agents); me.record_launched_agents(parent_conversation_id_for_result, &agents);
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Info,
"RunAgents launch outcomes resolved",
serde_json::json!({
"event": "run_agents_launch_outcomes_resolved",
"action_id": action_id_for_aggr.to_string(),
"parent_conversation_id": parent_conversation_id_for_result.to_string(),
"agent_count": agents.len(),
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. })).count(),
"failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
"agents": agents
.iter()
.map(|agent| match &agent.kind {
RunAgentsAgentOutcomeKind::Launched { agent_id } => serde_json::json!({
"name": agent.name.as_str(),
"status": "launched",
"agent_id": agent_id.as_str(),
}),
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => serde_json::json!({
"name": agent.name.as_str(),
"status": "completed",
"agent_id": agent_id.as_str(),
"output": output,
}),
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
"name": agent.name.as_str(),
"status": "failed",
"error": remote_logging::sanitize_error(error),
}),
})
.collect::<Vec<_>>(),
}),
);
let launched_mode = match &run_execution_mode_for_aggr { let launched_mode = match &run_execution_mode_for_aggr {
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local, RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
RunAgentsExecutionMode::Remote { RunAgentsExecutionMode::Remote {
@@ -360,8 +627,12 @@ impl RunAgentsExecutor {
execution_mode: launched_mode, execution_mode: launched_mode,
agents, agents,
}; };
me.pending.remove(&action_id_for_aggr); me.pending.remove(&(
parent_conversation_id_for_result,
action_id_for_aggr.clone(),
));
ctx.emit(RunAgentsExecutorEvent::SpawningFinished { ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
conversation_id: parent_conversation_id_for_result,
action_id: action_id_for_aggr, action_id: action_id_for_aggr,
}); });
let _ = sender.try_send(result); let _ = sender.try_send(result);
@@ -381,20 +652,58 @@ impl RunAgentsExecutor {
let mut request = request.clone(); let mut request = request.clone();
let action_id = id.clone(); let action_id = id.clone();
let parent_conversation_id = input.conversation_id; let parent_conversation_id = input.conversation_id;
if let Some(reason) = prepare_request_for_execution( let is_recovery = self
&mut request, .recovery_action_ids
parent_conversation_id, .remove(&(parent_conversation_id, action_id.clone()));
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
let receiver = let recovery_children = if is_recovery {
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx); prepare_recovery_request_for_execution(&mut request, parent_conversation_id, ctx);
match recovery_children_by_name(parent_conversation_id, ctx) {
Ok(children) => children,
Err(error) => {
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Failure { error },
));
}
}
} else {
if let Some(reason) = prepare_request_for_execution(
&mut request,
parent_conversation_id,
self.terminal_view_id,
&self.launched_agents,
ctx,
) {
#[cfg(not(target_family = "wasm"))]
log_run_agents_event(
ctx,
RemoteLogLevel::Warn,
"RunAgents execution denied",
serde_json::json!({
"event": "run_agents_execution_denied",
"action_id": action_id.to_string(),
"parent_conversation_id": parent_conversation_id.to_string(),
"reason": remote_logging::sanitize_error(&reason),
}),
);
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason },
));
}
HashMap::new()
};
let receiver = if is_recovery {
self.dispatch_recovered_run_agents(
action_id,
request,
parent_conversation_id,
recovery_children,
ctx,
)
} else {
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx)
};
ActionExecution::new_async( ActionExecution::new_async(
async move { receiver.recv().await }, async move { receiver.recv().await },
@@ -413,6 +722,9 @@ impl RunAgentsExecutor {
let AIAgentActionType::RunAgents(request) = &input.action.action else { let AIAgentActionType::RunAgents(request) = &input.action.action else {
return false; return false;
}; };
if child_agent_delegation_denial_reason(input.conversation_id, ctx).is_some() {
return true;
}
if AppExecutionMode::as_ref(ctx).is_autonomous() { if AppExecutionMode::as_ref(ctx).is_autonomous() {
return true; return true;
} }
@@ -444,9 +756,123 @@ impl RunAgentsExecutor {
#[path = "run_agents_tests.rs"] #[path = "run_agents_tests.rs"]
mod tests; mod tests;
#[cfg(not(target_family = "wasm"))]
fn log_run_agents_event(
ctx: &mut ModelContext<RunAgentsExecutor>,
level: RemoteLogLevel,
message: impl Into<String>,
context: serde_json::Value,
) {
remote_logging::log_model_event(
ctx,
RemoteLogRecord {
level,
message: message.into(),
context,
},
);
}
#[cfg(not(target_family = "wasm"))]
fn run_agents_execution_mode_label(mode: &RunAgentsExecutionMode) -> &'static str {
match mode {
RunAgentsExecutionMode::Local => "local",
RunAgentsExecutionMode::Remote { .. } => "remote",
}
}
#[cfg(not(target_family = "wasm"))]
fn start_agent_execution_mode_label(mode: &StartAgentExecutionMode) -> &'static str {
match mode {
StartAgentExecutionMode::Local { .. } => "local",
StartAgentExecutionMode::Remote { .. } => "remote",
}
}
#[cfg(not(target_family = "wasm"))]
fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
match kind {
RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
}
}
enum ChildSlot { enum ChildSlot {
Failed(String), Failed(String),
Pending(async_channel::Receiver<StartAgentOutcome>), Pending(StartAgentDispatch),
}
#[derive(Debug)]
struct ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind,
timed_out_request_id: Option<StartAgentRequestId>,
}
async fn resolve_child_slot(slot: ChildSlot) -> ResolvedChildSlot {
resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
}
async fn resolve_child_slot_with_timeout(
slot: ChildSlot,
spawn_timeout: Duration,
) -> ResolvedChildSlot {
let dispatch = match slot {
ChildSlot::Failed(error) => {
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed { error },
timed_out_request_id: None,
};
}
ChildSlot::Pending(dispatch) => dispatch,
};
let request_id = dispatch.request_id;
let outcome = match dispatch.wait_policy {
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
StartAgentWaitPolicy::Startup => {
let timeout = warpui::r#async::Timer::after(spawn_timeout);
match futures::future::select(Box::pin(dispatch.receiver.recv()), Box::pin(timeout))
.await
{
futures::future::Either::Left((outcome, _)) => outcome.ok(),
futures::future::Either::Right((_, _)) => {
dispatch.mark_detached();
log::warn!(
"Agent spawn timed out after {} seconds",
spawn_timeout.as_secs()
);
return ResolvedChildSlot {
outcome: RunAgentsAgentOutcomeKind::Failed {
error: format!(
"Agent failed to start within {} seconds. \
The harness binary may not be installed.",
spawn_timeout.as_secs()
),
},
timed_out_request_id: Some(request_id),
};
}
}
}
};
let outcome = match outcome {
Some(StartAgentOutcome::Started { agent_id }) => {
RunAgentsAgentOutcomeKind::Launched { agent_id }
}
Some(StartAgentOutcome::Completed { agent_id, output }) => {
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
}
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
None => RunAgentsAgentOutcomeKind::Failed {
error: "Child agent was cancelled before completion".to_string(),
},
};
ResolvedChildSlot {
outcome,
timed_out_request_id: None,
}
} }
fn approved_orchestration_config_can_autoexecute( fn approved_orchestration_config_can_autoexecute(
@@ -476,9 +902,9 @@ fn resolve_request_from_approved_config(
/// Normalizes the request and returns a denial reason when launch is blocked. /// Normalizes the request and returns a denial reason when launch is blocked.
/// ///
/// Autonomous agents always run: their calls may still inherit approved plan /// Root autonomous agents bypass interactive policy denials because they cannot
/// config fields and default auth secrets, but they bypass interactive policy /// present a confirmation card. Child-agent delegation is rejected before that
/// denials because they cannot present a confirmation card. /// bypass, while allowed root calls still inherit approved config and auth fields.
fn prepare_request_for_execution( fn prepare_request_for_execution(
request: &mut RunAgentsRequest, request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
@@ -486,6 +912,11 @@ fn prepare_request_for_execution(
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>, launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>, ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> { ) -> Option<String> {
if let Some(reason) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return Some(reason);
}
normalize_request_for_local_execution(request);
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx); let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx); populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) = if let Some(reason) =
@@ -521,6 +952,42 @@ fn prepare_request_for_execution(
None None
} }
fn prepare_recovery_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) {
normalize_request_for_local_execution(request);
resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
}
fn recovery_children_by_name(
parent_conversation_id: AIConversationId,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Result<HashMap<String, AIConversationId>, String> {
let mut children_by_name = HashMap::new();
for conversation in
BlocklistAIHistoryModel::as_ref(ctx).child_conversations_of(parent_conversation_id)
{
let Some(name) = conversation.agent_name() else {
continue;
};
let Some(normalized_name) = normalize_agent_name(name) else {
continue;
};
if children_by_name
.insert(normalized_name.clone(), conversation.id())
.is_some()
{
return Err(format!(
"Cannot recover child agent '{name}': multiple persisted child conversations have the same name."
));
}
}
Ok(children_by_name)
}
fn duplicate_launched_agents_reason( fn duplicate_launched_agents_reason(
request: &RunAgentsRequest, request: &RunAgentsRequest,
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
@@ -544,8 +1011,11 @@ fn duplicate_launched_agents_reason(
let duplicates = requested_agents let duplicates = requested_agents
.iter() .iter()
.map(|(normalized_name, _)| existing_agents.get(normalized_name)) .filter_map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Option<Vec<_>>>()?; .collect::<Vec<_>>();
if duplicates.is_empty() {
return None;
}
let duplicate_list = duplicates let duplicate_list = duplicates
.iter() .iter()
.map(|agent| format!("{} ({})", agent.name, agent.agent_id)) .map(|agent| format!("{} ({})", agent.name, agent.agent_id))
@@ -590,6 +1060,19 @@ fn existing_launched_agents_for_conversation(
}; };
for agent in agents { for agent in agents {
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else { let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind
else {
continue;
};
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
continue;
};
existing_agents.entry(normalized_name).or_insert_with(|| {
ExistingLaunchedAgent {
name: agent.name.clone(),
agent_id: agent_id.clone(),
}
});
continue; continue;
}; };
let Some(normalized_name) = normalize_agent_name(&agent.name) else { let Some(normalized_name) = normalize_agent_name(&agent.name) else {
@@ -673,6 +1156,18 @@ fn populate_default_auth_secret_for_execution(
default_auth_secret_name_for_harness(&request.harness_type, ctx); default_auth_secret_name_for_harness(&request.harness_type, ctx);
} }
fn normalize_request_for_local_execution(request: &mut RunAgentsRequest) {
let edit_state = OrchestrationEditState::from_run_agents_fields(
&request.model_id,
&request.harness_type,
&request.execution_mode,
);
request.model_id = edit_state.model_id;
request.harness_type = edit_state.harness_type;
request.execution_mode = RunAgentsExecutionMode::Local;
request.harness_auth_secret_name = None;
}
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest` /// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
/// from the approved orchestration config, delegating to /// from the approved orchestration config, delegating to
/// `OrchestrationEditState::override_from_approved_config`. /// `OrchestrationEditState::override_from_approved_config`.
@@ -696,6 +1191,23 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
if request.agent_run_configs.is_empty() { if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string()); return Err("orchestrate: empty agent_run_configs".to_string());
} }
if request.execution_mode.is_remote() {
return Err("Galaxy only supports local child-agent orchestration.".to_string());
}
let mut normalized_names = HashSet::new();
for config in &request.agent_run_configs {
let Some(normalized_name) = normalize_agent_name(&config.name) else {
return Err("orchestrate: agent names must not be empty".to_string());
};
if !normalized_names.insert(normalized_name) {
return Err(format!(
"orchestrate: duplicate agent name '{}' in the same batch",
config.name.trim()
));
}
}
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) { if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) { if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
if let Some(message) = local_harness_product_disabled_message(harness) { if let Some(message) = local_harness_product_disabled_message(harness) {
@@ -1,4 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
use ai::agent::orchestration_config::{ use ai::agent::orchestration_config::{
@@ -91,6 +93,15 @@ fn persist_plan_config_with_harness(
}); });
} }
fn mark_conversation_as_child(app: &mut App, conversation_id: AIConversationId) {
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
});
}
#[test] #[test]
fn should_autoexecute_duplicate_launched_agent_denial() { fn should_autoexecute_duplicate_launched_agent_denial() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -162,6 +173,522 @@ fn execute_denies_duplicate_launched_agent() {
}); });
} }
#[test]
fn execute_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(
should_autoexecute,
"the denial should not require user approval"
);
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn autonomous_mode_still_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn execute_denies_mixed_batch_containing_launched_agent() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
state.executor.update(&mut app, |executor, _ctx| {
executor.record_launched_agents(
state.conversation_id,
&[RunAgentsAgentOutcome {
name: "child".to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: "agent-123".to_string(),
},
}],
);
});
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "new-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("child (agent-123)")
));
});
}
#[test]
fn validate_request_rejects_blank_and_duplicate_agent_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
normalize_request_for_local_execution(&mut request);
request.agent_run_configs[0].name = " ".to_string();
assert_eq!(
validate_request(&request),
Err("orchestrate: agent names must not be empty".to_string())
);
request.agent_run_configs[0].name = "Child".to_string();
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: " child ".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(
validate_request(&request),
Err("orchestrate: duplicate agent name 'child' in the same batch".to_string())
);
}
#[test]
fn validate_request_allows_unique_sibling_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
normalize_request_for_local_execution(&mut request);
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "second-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn local_normalization_clears_remote_only_fields_and_disabled_harness() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("codex").action else {
panic!("expected run_agents action");
};
request.model_id = "gpt-5".to_string();
request.harness_auth_secret_name = Some("remote-secret".to_string());
normalize_request_for_local_execution(&mut request);
assert!(matches!(
request.execution_mode,
RunAgentsExecutionMode::Local
));
assert_eq!(request.harness_type, "oz");
assert_eq!(request.model_id, "");
assert_eq!(request.harness_auth_secret_name, None);
assert_eq!(validate_request(&request), Ok(()));
}
#[test]
fn validate_request_rejects_remote_dispatch() {
let AIAgentActionType::RunAgents(request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
assert_eq!(
validate_request(&request),
Err("Galaxy only supports local child-agent orchestration.".to_string())
);
}
#[test]
fn recovered_run_agents_reattaches_existing_child_and_launches_only_missing_child() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
let terminal_view_id = EntityId::new();
let history = BlocklistAIHistoryModel::handle(&app);
let existing_child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
state.conversation_id,
None,
ctx,
)
});
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "missing-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
state.executor.update(&mut app, |executor, _| {
executor
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async recovery execution");
};
let missing_request = captured.read(&app, |captured, _| {
assert_eq!(captured.0.len(), 1);
assert_eq!(captured.0[0].name, "missing-child");
captured.0[0].clone()
});
history.update(&mut app, |history, ctx| {
history.update_conversation_status(
terminal_view_id,
existing_child_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
let missing_child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"missing-child".to_string(),
state.conversation_id,
None,
ctx,
)
});
history.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
missing_request.id,
missing_child_id,
ctx,
);
history.update_conversation_status(
terminal_view_id,
missing_child_id,
crate::ai::agent::conversation::ConversationStatus::Success,
ctx,
);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched { agents, .. }) = result
else {
panic!("expected recovered RunAgents result");
};
assert_eq!(agents.len(), 2);
assert!(matches!(
&agents[0].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
if agent_id == &existing_child_id.to_string()
));
assert!(matches!(
&agents[1].kind,
RunAgentsAgentOutcomeKind::Launched { agent_id }
if agent_id == &missing_child_id.to_string()
));
});
}
#[test]
fn cancelling_recovered_run_agents_keeps_persisted_child_running() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
let terminal_view_id = EntityId::new();
let history = BlocklistAIHistoryModel::handle(&app);
let child_id = history.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
state.conversation_id,
None,
ctx,
)
});
let action = remote_run_agents_action("oz");
state.executor.update(&mut app, |executor, _| {
executor
.mark_recovery_actions(state.conversation_id, &HashSet::from([action.id.clone()]));
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
let AnyActionExecution::Async {
execute_future,
on_complete,
} = execution
else {
panic!("expected async recovery execution");
};
state.executor.update(&mut app, |executor, ctx| {
executor.cancel_execution(state.conversation_id, &action.id, ctx);
});
let async_result = execute_future.await;
let result = app.update(|ctx| on_complete(async_result, ctx));
assert!(matches!(
result,
AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled)
));
history.read(&app, |history, _| {
assert!(matches!(
history.conversation(&child_id).map(|child| child.status()),
Some(crate::ai::agent::conversation::ConversationStatus::InProgress)
));
});
});
}
#[test]
fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
App::test((), |_app| async move {
let (first_sender, first_receiver) = async_channel::bounded(1);
let (second_sender, second_receiver) = async_channel::bounded(1);
let slots = vec![
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver: first_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(2),
receiver: second_receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
ChildSlot::Failed("prelaunch failure".to_string()),
];
let mut outcomes =
Box::pin(join_all(slots.into_iter().map(|slot| {
resolve_child_slot_with_timeout(slot, Duration::from_millis(1))
})));
second_sender
.try_send(StartAgentOutcome::Completed {
agent_id: "second-agent".to_string(),
output: "done".to_string(),
})
.unwrap();
assert!(futures::poll!(&mut outcomes).is_pending());
assert!(
!second_sender.is_full(),
"join_all should poll and drain the second slot while the first is pending"
);
first_sender
.try_send(StartAgentOutcome::Error("first failed".to_string()))
.unwrap();
let outcomes = outcomes.await;
assert!(matches!(
&outcomes[0].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
));
assert!(matches!(
&outcomes[1].outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "second-agent" && output == "done"
));
assert!(matches!(
&outcomes[2].outcome,
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure"
));
});
}
#[test]
fn completion_wait_ignores_spawn_timeout() {
App::test((), |_app| async move {
let (sender, receiver) = async_channel::bounded(1);
let completion = Box::pin(resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Completion,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
));
let wait = warpui::r#async::Timer::after(Duration::from_millis(20));
let completion = match futures::future::select(completion, Box::pin(wait)).await {
futures::future::Either::Left((outcome, _)) => {
panic!("completion wait unexpectedly resolved before child completion: {outcome:?}")
}
futures::future::Either::Right((_, completion)) => completion,
};
sender
.try_send(StartAgentOutcome::Completed {
agent_id: "child-agent".to_string(),
output: "done".to_string(),
})
.unwrap();
assert!(matches!(
completion.await.outcome,
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
if agent_id == "child-agent" && output == "done"
));
});
}
#[test]
fn startup_wait_retains_spawn_timeout() {
App::test((), |_app| async move {
let (_sender, receiver) = async_channel::bounded(1);
let outcome = resolve_child_slot_with_timeout(
ChildSlot::Pending(StartAgentDispatch {
request_id: StartAgentRequestId::from_raw_for_test(1),
receiver,
wait_policy: StartAgentWaitPolicy::Startup,
detached: Arc::new(AtomicBool::new(false)),
}),
Duration::from_millis(1),
)
.await;
assert!(outcome.timed_out_request_id.is_some());
assert_eq!(
outcome.timed_out_request_id,
Some(StartAgentRequestId::from_raw_for_test(1))
);
assert!(matches!(
outcome.outcome,
RunAgentsAgentOutcomeKind::Failed { error }
if error.contains("Agent failed to start within")
));
});
}
#[test]
fn startup_timeout_detaches_exact_pending_request() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let start_agent_executor = state.start_agent_executor;
let parent_conversation_id = state.conversation_id;
let dispatch = start_agent_executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-timeout".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some("parent-run".to_string()),
ctx,
)
});
let request_id = dispatch.request_id;
let resolved =
resolve_child_slot_with_timeout(ChildSlot::Pending(dispatch), Duration::from_millis(1))
.await;
let timed_out_request_id = resolved
.timed_out_request_id
.expect("startup timeout should expose request identity");
start_agent_executor.update(&mut app, |executor, _| {
assert!(executor.detach_dispatch(timed_out_request_id));
});
start_agent_executor.read(&app, |executor, _| {
assert!(!executor.has_pending_dispatch_for_test(request_id));
});
});
}
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState { fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false); initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app); let global_resource_handles = GlobalResourceHandles::mock(app);
@@ -178,6 +705,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
app.add_singleton_model(TeamTesterStatus::mock); app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock); app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock); app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| AIDocumentModel::new_for_test()); app.add_singleton_model(|_| AIDocumentModel::new_for_test());
app.add_singleton_model(|_| TemplatableMCPServerManager::default()); app.add_singleton_model(|_| TemplatableMCPServerManager::default());
@@ -190,8 +720,9 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
}); });
let start_agent_executor = app.add_model(StartAgentExecutor::new); let start_agent_executor = app.add_model(StartAgentExecutor::new);
let executor = let executor = app.add_model(|ctx| {
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id)); RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id, ctx)
});
RunAgentsTestState { RunAgentsTestState {
conversation_id, conversation_id,
@@ -321,7 +852,7 @@ fn should_autoexecute_when_plan_has_approved_orchestration_config() {
} }
#[test] #[test]
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() { fn approved_remote_plan_is_normalized_and_can_autoexecute_locally() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
persist_plan_config_with_harness( persist_plan_config_with_harness(
@@ -343,7 +874,7 @@ fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_sec
) )
}); });
assert!(!should_autoexecute); assert!(should_autoexecute);
}); });
} }
@@ -561,9 +1092,9 @@ fn cancel_during_plan_publication_does_not_dispatch_children() {
// The action is awaiting plan publication, so it's pending but no children dispatched yet. // The action is awaiting plan publication, so it's pending but no children dispatched yet.
assert!(matches!(execution, AnyActionExecution::Async { .. })); assert!(matches!(execution, AnyActionExecution::Async { .. }));
state.executor.update(&mut app, |executor, ctx| { state.executor.update(&mut app, |executor, ctx| {
assert!(executor.is_pending(&action_id)); assert!(executor.is_pending(state.conversation_id, &action_id));
executor.cancel_execution(&action_id, ctx); executor.cancel_execution(state.conversation_id, &action_id, ctx);
assert!(!executor.is_pending(&action_id)); assert!(!executor.is_pending(state.conversation_id, &action_id));
}); });
// Finish publishing the plan, which resolves the wait the dispatch was blocked on. // Finish publishing the plan, which resolves the wait the dispatch was blocked on.
@@ -617,7 +1148,7 @@ fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
} }
#[test] #[test]
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() { fn execute_normalizes_remote_non_oz_harness_without_requiring_remote_auth() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
let action = remote_run_agents_action("codex"); let action = remote_run_agents_action("codex");
@@ -634,21 +1165,12 @@ fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
.into() .into()
}); });
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied { assert!(matches!(execution, AnyActionExecution::Async { .. }));
reason,
})) = execution
else {
panic!("expected synchronous run_agents denial");
};
assert_eq!(
reason,
"Cloud child agents using this harness require an API key before they can run."
);
}); });
} }
#[test] #[test]
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() { fn normalized_remote_non_oz_harness_autoexecutes_with_always_allow() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -669,7 +1191,7 @@ fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_def
} }
#[test] #[test]
fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() { fn normalized_remote_non_oz_harness_ignores_default_auth_secret() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -691,7 +1213,7 @@ fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
} }
#[test] #[test]
fn should_autoexecute_remote_warp_harness_without_default_auth_secret() { fn normalized_remote_oz_harness_autoexecutes_without_default_auth_secret() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App); let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow); set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_util::path::ShellFamily; use galaxy_util::path::ShellFamily;
use galaxyui::r#async::{Spawnable, Timer}; use galaxyui::r#async::{Spawnable, Timer};
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex; use parking_lot::FairMutex;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput}; use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
pub struct ShellCommandExecutor { pub struct ShellCommandExecutor {
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>, block_finished_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running /// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
/// shell command's pending poll future to resolve immediately with a fresh snapshot, /// shell command's pending poll future to resolve immediately with a fresh snapshot,
/// bypassing the agent-set timeout. /// bypassing the agent-set timeout.
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>, force_refresh_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
terminal_model: Arc<FairMutex<TerminalModel>>, terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId, terminal_view_id: EntityId,
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser. /// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
event: &ModelEvent, event: &ModelEvent,
_ctx: &mut ModelContext<Self>, _ctx: &mut ModelContext<Self>,
) { ) {
// We wait for precmd for the block _after_ the requested command's block so that // Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
// downstream checks for current working directory are fresh. The precmd hook is when // evidence for shells that never deliver a subsequent precmd.
// the shell relays current working directory to warp. if matches!(
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event { event,
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
| ModelEvent::BlockCompleted(_)
) {
let model = self.terminal_model.lock(); let model = self.terminal_model.lock();
let block_finished_senders = self.block_finished_senders.drain().collect_vec(); let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() { for (block_selector, block_finished_txs) in block_finished_senders {
if let Some(block) = block_selector.get_block(&model) { let completed_block = block_selector.get_block(&model).filter(|block| {
if block.is_command_finished() { block.is_command_finished()
&& match event {
ModelEvent::BlockCompleted(completed) => {
block.id() == &completed.block_id
}
ModelEvent::BlockMetadataReceived(_) => true,
_ => false,
}
});
if completed_block.is_some() {
for block_finished_tx in block_finished_txs {
if let Err(e) = block_finished_tx.send(()) { if let Err(e) = block_finished_tx.send(()) {
log::warn!( log::warn!(
"Failed to notify block completion for running requested command: {e:?}" "Failed to notify block completion for running requested command: {e:?}"
) )
} }
} else {
self.block_finished_senders
.insert(block_selector, block_finished_tx);
} }
} else {
// The requested-command association may not exist yet. Keep all waiters until
// this selector resolves and its block actually completes, or it is cancelled.
self.block_finished_senders
.insert(block_selector, block_finished_txs);
} }
} }
} }
@@ -190,30 +204,13 @@ impl ShellCommandExecutor {
} }
} }
/// Decorate the command so that we can turn off pager.
fn turn_off_pager_for_command(&self, command: &String, ctx: &mut ModelContext<Self>) -> String {
match self.active_session.as_ref(ctx).shell_type(ctx) {
// If it's a posix shell, we can use parentheses as the grouping character. Add command to
// avoid cases with aliases.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!("({command}) | command cat"),
// Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command
// gets evaluated first.
Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"),
// For powershell, we use Out-Host to send paged output to the
// console. Add a backslash to avoid executing an alias.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
// If we can't determine a shell type, run command as it is.
None => command.clone(),
}
}
pub(super) fn execute( pub(super) fn execute(
&mut self, &mut self,
input: ExecuteActionInput, input: ExecuteActionInput,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> impl Into<AnyActionExecution> { ) -> impl Into<AnyActionExecution> {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}", "ShellCommandExecutor::execute: action_type={:?}",
std::mem::discriminant(&input.action.action) std::mem::discriminant(&input.action.action)
); );
let model = self.terminal_model.lock(); let model = self.terminal_model.lock();
@@ -221,17 +218,10 @@ impl ShellCommandExecutor {
// Determine the action we want to take based on the input. // Determine the action we want to take based on the input.
let action_id = input.action.id.clone(); let action_id = input.action.id.clone();
let command = model
.block_list()
.active_block()
.command_with_secrets_unobfuscated(false)
.clone();
let handle = ctx.handle(); let handle = ctx.handle();
match &input.action.action { match &input.action.action {
AIAgentActionType::RequestCommandOutput { AIAgentActionType::RequestCommandOutput {
command, command,
uses_pager,
wait_until_completion, wait_until_completion,
.. ..
} => { } => {
@@ -240,18 +230,13 @@ impl ShellCommandExecutor {
.active_block() .active_block()
.is_active_and_long_running() .is_active_and_long_running()
{ {
// Another command is still running (e.g. stuck in a pager). Return an error let running_command = model
// result so the model receives feedback and can adapt. Using Completed with a .block_list()
// non-zero exit code ensures a follow-up request is triggered. .active_block()
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput( .command_with_secrets_unobfuscated(false);
RequestCommandOutputResult::Completed { return ActionExecution::Sync(terminal_busy_execution_error(
command: command.clone(), command,
block_id: model.block_list().active_block().id().clone(), &running_command,
output: "Error: Cannot execute command because another command is still running in the terminal.".to_string(),
exit_code: ExitCode::from(1),
start_ts: None,
completed_ts: None,
},
)); ));
} }
// If another conversation has taken over the agent view since this command // If another conversation has taken over the agent view since this command
@@ -266,15 +251,13 @@ impl ShellCommandExecutor {
RequestCommandOutputResult::CancelledBeforeExecution, RequestCommandOutputResult::CancelledBeforeExecution,
)); ));
} }
// If the command might use pager and can't be interacted with, // A command expected to finish must not enter an implicit pager. Do not trust the
// we pipe its output to cat so we can prevent activating the altscreen. // model-provided pager hint: commands such as `git log` can page implicitly.
// The parentheses here ensures the command always gets evaluated first. let decorated_command = command_for_execution(
let decorated_command = command,
if uses_pager.is_some_and(|uses_pager| uses_pager) && *wait_until_completion { self.active_session.as_ref(ctx).shell_type(ctx),
self.turn_off_pager_for_command(command, ctx) *wait_until_completion,
} else { );
command.clone()
};
ctx.emit(ShellCommandExecutorEvent::ExecuteCommand { ctx.emit(ShellCommandExecutorEvent::ExecuteCommand {
action_id: action_id.clone(), action_id: action_id.clone(),
command: decorated_command, command: decorated_command,
@@ -295,8 +278,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps. // Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.force_refresh_senders.remove(&block_selector);
}); });
} }
@@ -359,8 +341,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps. // Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.force_refresh_senders.remove(&block_selector);
}); });
} }
@@ -391,6 +372,7 @@ impl ShellCommandExecutor {
}, },
)); ));
} }
let command = block.command_with_secrets_unobfuscated(false);
drop(model); drop(model);
let block_selector = BlockSelector::Id(block_id.clone()); let block_selector = BlockSelector::Id(block_id.clone());
@@ -400,8 +382,7 @@ impl ShellCommandExecutor {
// Remove the senders from the maps. // Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.force_refresh_senders.remove(&block_selector);
}); });
} }
@@ -439,7 +420,9 @@ impl ShellCommandExecutor {
// Set up a future to also wait for block completion. // Set up a future to also wait for block completion.
let (block_finished_tx, block_finished_rx) = oneshot::channel(); let (block_finished_tx, block_finished_rx) = oneshot::channel();
self.block_finished_senders self.block_finished_senders
.insert(block_selector.clone(), block_finished_tx); .entry(block_selector.clone())
.or_default()
.push(block_finished_tx);
// Build the future that captures terminal model and block data. // Build the future that captures terminal model and block data.
let transfer_future = { let transfer_future = {
@@ -511,7 +494,7 @@ impl ShellCommandExecutor {
// Clean up. // Clean up.
if let Some(handle) = handle.upgrade(ctx) { if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| { handle.update(ctx, |me, _| {
me.block_finished_senders.remove(&block_selector); me.prune_closed_senders(&block_selector);
me.control_handback_sender = None; me.control_handback_sender = None;
}); });
} }
@@ -540,13 +523,17 @@ impl ShellCommandExecutor {
// Create a channel to notify us when we receive block metadata. // Create a channel to notify us when we receive block metadata.
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel(); let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
self.block_finished_senders self.block_finished_senders
.insert(block_selector.clone(), block_metadata_received_tx); .entry(block_selector.clone())
.or_default()
.push(block_metadata_received_tx);
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit // Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
// the timeout and deliver the agent a fresh snapshot immediately. // the timeout and deliver the agent a fresh snapshot immediately.
let (force_refresh_tx, force_refresh_rx) = oneshot::channel(); let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
self.force_refresh_senders self.force_refresh_senders
.insert(block_selector.clone(), force_refresh_tx); .entry(block_selector.clone())
.or_default()
.push(force_refresh_tx);
// Create a future that resolves when we should send a result to the agent. // Create a future that resolves when we should send a result to the agent.
let terminal_model = self.terminal_model.clone(); let terminal_model = self.terminal_model.clone();
@@ -620,7 +607,12 @@ impl ShellCommandExecutor {
completed_ts: block.completed_ts().cloned(), completed_ts: block.completed_ts().cloned(),
} }
} else { } else {
let grid_contents = if model.is_alt_screen_active() { let selected_block_owns_alt_screen = selected_block_owns_alt_screen(
model.is_alt_screen_active(),
model.active_block_id(),
block.id(),
);
let grid_contents = if selected_block_owns_alt_screen {
formatted_terminal_contents_for_input( formatted_terminal_contents_for_input(
model.alt_screen().grid_handler(), model.alt_screen().grid_handler(),
None, None,
@@ -638,7 +630,7 @@ impl ShellCommandExecutor {
block_id: block.id().clone(), block_id: block.id().clone(),
grid_contents, grid_contents,
cursor: CURSOR_MARKER, cursor: CURSOR_MARKER,
is_alt_screen_active: model.is_alt_screen_active(), is_alt_screen_active: selected_block_owns_alt_screen,
is_preempted, is_preempted,
} }
} }
@@ -650,23 +642,50 @@ impl ShellCommandExecutor {
} }
} }
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) { pub(super) fn cancel_execution(
&mut self,
id: &AIAgentActionId,
ctx: &mut ModelContext<Self>,
) -> bool {
let terminal_model = self.terminal_model.lock(); let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block(); let requested_selector = BlockSelector::RequestedCommandId(id.clone());
if !active_block.is_active_and_long_running() { let requested_block_is_running = requested_selector
return; .get_block(&terminal_model)
} .is_some_and(|block| block.is_active_and_long_running() && !block.finished());
let selector = if requested_block_is_running {
let selector = if active_block requested_selector
.requested_command_action_id()
.is_some_and(|requested_command_id| requested_command_id == id)
{
BlockSelector::RequestedCommandId(id.clone())
} else { } else {
BlockSelector::Id(active_block.id().clone()) BlockSelector::Id(terminal_model.active_block_id().clone())
}; };
self.block_finished_senders.remove(&selector); // Cancelling the wait future alone would report cancellation while the process keeps
self.force_refresh_senders.remove(&selector); // running. Terminate the exact requested command before resolving the action as cancelled.
if requested_block_is_running {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: id.clone(),
});
}
if !requested_block_is_running {
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
}
requested_block_is_running
}
fn prune_closed_senders(&mut self, selector: &BlockSelector) {
Self::prune_closed_sender_group(&mut self.block_finished_senders, selector);
Self::prune_closed_sender_group(&mut self.force_refresh_senders, selector);
}
fn prune_closed_sender_group(
senders: &mut HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
selector: &BlockSelector,
) {
if let Some(selector_senders) = senders.get_mut(selector) {
selector_senders.retain(|sender| !sender.is_canceled());
if selector_senders.is_empty() {
senders.remove(selector);
}
}
} }
/// Force any in-flight poll for the given long-running command block to resolve /// Force any in-flight poll for the given long-running command block to resolve
@@ -677,23 +696,28 @@ impl ShellCommandExecutor {
/// control to the user). Returns whether a matching poll was successfully refreshed. /// control to the user). Returns whether a matching poll was successfully refreshed.
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool { pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
let terminal_model = self.terminal_model.lock(); let terminal_model = self.terminal_model.lock();
// Find a sender whose selector resolves to this block. In practice there is at // Find every pending poll whose selector resolves to this block. Multiple provider polls
// most one: a given block can have at most one in-flight `action_result_future` // may legitimately wait on the same command and must be refreshed together.
// at a time.
let matching_selector = self let matching_selector = self
.force_refresh_senders .force_refresh_senders
.keys() .keys()
.find(|selector| { .find(|selector| {
selector selector.get_block(&terminal_model).is_some_and(|block| {
.get_block(&terminal_model) block.id() == block_id
.is_some_and(|block| block.id() == block_id) && block.is_active_and_long_running()
&& !block.finished()
})
}) })
.cloned(); .cloned();
drop(terminal_model); drop(terminal_model);
if let Some(selector) = matching_selector { if let Some(selector) = matching_selector {
if let Some(sender) = self.force_refresh_senders.remove(&selector) { if let Some(senders) = self.force_refresh_senders.remove(&selector) {
return sender.send(()).is_ok(); let mut refreshed = false;
for sender in senders {
refreshed |= sender.send(()).is_ok();
}
return refreshed;
} }
} }
false false
@@ -708,6 +732,45 @@ impl ShellCommandExecutor {
} }
} }
fn command_for_execution(
command: &str,
shell_type: Option<ShellType>,
wait_until_completion: bool,
) -> String {
if !wait_until_completion {
return command.to_string();
}
match shell_type {
// Pager environment variables preserve the command's output and exit status, unlike piping
// through `cat`. Tool-specific variables override user configuration for common pagers.
Some(ShellType::Zsh) | Some(ShellType::Bash) => format!(
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; {command})"
),
Some(ShellType::Fish) => format!(
"begin; set -lx PAGER cat; set -lx GIT_PAGER cat; set -lx GH_PAGER cat; set -lx AWS_PAGER cat; set -lx SYSTEMD_PAGER cat; {command}; end"
),
// PowerShell's pipeline host suppresses paging for commands that honor the host stream.
Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"),
None => command.to_string(),
}
}
fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgentActionResultType {
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
command: command.to_string(),
message: format!("terminal is busy running command '{running_command}'"),
})
}
fn selected_block_owns_alt_screen(
is_alt_screen_active: bool,
active_block_id: &BlockId,
selected_block_id: &BlockId,
) -> bool {
is_alt_screen_active && active_block_id == selected_block_id
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum BlockSelector { enum BlockSelector {
Id(BlockId), Id(BlockId),
@@ -913,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
input: Bytes, input: Bytes,
mode: AIAgentPtyWriteMode, mode: AIAgentPtyWriteMode,
}, },
CancelExecution, CancelExecution {
action_id: AIAgentActionId,
},
/// Emitted when the agent requests to transfer control of a long-running command to the user. /// Emitted when the agent requests to transfer control of a long-running command to the user.
TransferControlToUser { TransferControlToUser {
action_id: AIAgentActionId, action_id: AIAgentActionId,
@@ -1,18 +1,80 @@
use std::sync::Arc; use std::sync::Arc;
use std::task::Poll;
use async_channel::unbounded; use async_channel::unbounded;
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::{pin_mut, poll};
use parking_lot::FairMutex; use parking_lot::FairMutex;
use warpui::{App, EntityId}; use warpui::{App, EntityId};
use super::{ActionResult, BlockSelector, ShellCommandExecutor}; use super::{
use crate::ai::agent::ShellCommandDelay; command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent}; ActionResult, BlockSelector, ShellCommandExecutor,
};
use crate::ai::agent::{
AIAgentActionId, AIAgentActionResultType, RequestCommandOutputResult, ShellCommandDelay,
};
use crate::terminal::event::{
BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BlockWorkingDirectoryUpdatedEvent,
};
use crate::terminal::model::block::{BlockId, BlockMetadata}; use crate::terminal::model::block::{BlockId, BlockMetadata};
use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model::session::Sessions; use crate::terminal::model::session::Sessions;
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel}; use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
use crate::terminal::shell::ShellType;
use crate::AIConversationId;
#[test]
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
let command = "git log -8 --oneline && false";
let decorated = command_for_execution(command, Some(ShellType::Zsh), true);
assert_eq!(
decorated,
"(export PAGER=cat GIT_PAGER=cat GH_PAGER=cat AWS_PAGER=cat SYSTEMD_PAGER=cat; git log -8 --oneline && false)"
);
assert!(!decorated.contains("| command cat"));
assert_eq!(
command_for_execution(command, Some(ShellType::Zsh), false),
command
);
}
#[test]
fn terminal_busy_is_an_execution_error_for_the_unstarted_command() {
let result = terminal_busy_execution_error("cargo test", "sleep 120");
assert!(matches!(
result,
AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::ExecutionError { command, message }
) if command == "cargo test"
&& message == "terminal is busy running command 'sleep 120'"
));
}
#[test]
fn targeted_poll_uses_alt_screen_only_for_its_owning_block() {
let active_block_id = BlockId::new();
let selected_block_id = BlockId::new();
assert!(!selected_block_owns_alt_screen(
true,
&active_block_id,
&selected_block_id
));
assert!(selected_block_owns_alt_screen(
true,
&active_block_id,
&active_block_id
));
assert!(!selected_block_owns_alt_screen(
false,
&active_block_id,
&active_block_id
));
}
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish /// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to /// detector reacts only to `BlockMetadataReceived` (precmd) and not to
@@ -46,7 +108,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
let selector = BlockSelector::Id(block_id); let selector = BlockSelector::Id(block_id);
let (tx, _rx) = oneshot::channel::<()>(); let (tx, _rx) = oneshot::channel::<()>();
executor.update(&mut app, |executor, _ctx| { executor.update(&mut app, |executor, _ctx| {
executor.block_finished_senders.insert(selector, tx); executor.block_finished_senders.insert(selector, vec![tx]);
}); });
assert_eq!( assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()), app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
@@ -71,8 +133,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
that map is reserved for precmd (BlockMetadataReceived)" that map is reserved for precmd (BlockMetadataReceived)"
); );
// Precmd event — the senders map should be drained (and since the // An unrelated precmd cannot resolve this selector, so its waiter must survive.
// block isn't in the terminal model, the sender is dropped).
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| { model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived( ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent { BlockMetadataReceivedEvent {
@@ -85,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
}); });
assert_eq!( assert_eq!(
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()), app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
0, 1,
"BlockMetadataReceived should drain the finish senders" "BlockMetadataReceived must retain unresolved finish senders"
); );
}); });
} }
@@ -103,11 +164,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx) ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
}); });
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None))); let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 120", "still running");
let block_id = terminal_model.lock().active_block_id().clone(); let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| { let executor = app.add_model(|ctx| {
ShellCommandExecutor::new( ShellCommandExecutor::new(
active_session, active_session,
terminal_model, terminal_model.clone(),
&model_event_dispatcher, &model_event_dispatcher,
terminal_view_id, terminal_view_id,
ctx, ctx,
@@ -118,15 +182,170 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
executor.update(&mut app, |executor, _| { executor.update(&mut app, |executor, _| {
executor executor
.force_refresh_senders .force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), tx); .insert(BlockSelector::Id(block_id.clone()), vec![tx]);
assert!(executor.force_refresh_block(&block_id)); assert!(executor.force_refresh_block(&block_id));
assert!(!executor.force_refresh_block(&block_id)); assert!(!executor.force_refresh_block(&block_id));
}); });
assert!(matches!(rx.try_recv(), Ok(Some(())))); assert!(matches!(rx.try_recv(), Ok(Some(()))));
let (tx, _rx) = oneshot::channel();
executor.update(&mut app, |executor, _| {
executor
.force_refresh_senders
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
});
terminal_model.lock().finish_block();
assert!(executor.update(&mut app, |executor, _| {
!executor.force_refresh_block(&block_id)
}));
}); });
} }
#[test]
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let action_id = AIAgentActionId::from("requested-command".to_string());
let result_future = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::RequestedCommandId(action_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(result_future);
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockMetadataReceived(
BlockMetadataReceivedEvent {
block_metadata: BlockMetadata::new(None, Some("/tmp/early".to_string())),
block_index: BlockIndex::zero(),
is_after_in_band_command: false,
is_done_bootstrapping: true,
},
));
});
assert!(matches!(poll!(&mut result_future), Poll::Pending));
terminal_model
.lock()
.simulate_long_running_block("printf done", "done");
let block_id = terminal_model.lock().active_block_id().clone();
terminal_model
.lock()
.block_list_mut()
.active_block_mut()
.set_agent_interaction_mode_for_requested_command(
action_id,
None,
AIConversationId::new(),
);
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
assert!(matches!(
result_future.await,
ActionResult::CommandFinished {
block_id: result_block_id,
..
} if result_block_id == block_id
));
});
}
#[test]
fn duplicate_completion_polls_for_same_block_both_resolve_on_block_completed() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let sessions = app.add_model(|_| Sessions::new_for_test());
let (_model_events_tx, model_events_rx) = unbounded();
let model_event_dispatcher =
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
let active_session = app.add_model(|ctx| {
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
});
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
terminal_model
.lock()
.simulate_long_running_block("sleep 1", "finished");
let block_id = terminal_model.lock().active_block_id().clone();
let executor = app.add_model(|ctx| {
ShellCommandExecutor::new(
active_session,
terminal_model.clone(),
&model_event_dispatcher,
terminal_view_id,
ctx,
)
});
let first = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
let second = executor.update(&mut app, |executor, _| {
executor.action_result_future(
BlockSelector::Id(block_id.clone()),
Some(ShellCommandDelay::OnCompletion),
)
});
pin_mut!(first);
pin_mut!(second);
assert!(matches!(poll!(&mut first), Poll::Pending));
assert!(matches!(poll!(&mut second), Poll::Pending));
terminal_model.lock().finish_block();
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
block_id.clone(),
)));
});
let first_result = first.await;
let second_result = second.await;
assert!(matches!(first_result, ActionResult::CommandFinished { .. }));
assert!(matches!(
second_result,
ActionResult::CommandFinished { .. }
));
});
}
fn block_completed_event(block_id: BlockId) -> BlockCompletedEvent {
BlockCompletedEvent {
block_latency_data: None,
block_type: BlockType::Restored,
num_secrets_obfuscated: 0,
block_index: BlockIndex::zero(),
block_id,
session_id: None,
restored_block_was_local: None,
}
}
#[test] #[test]
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() { fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -1,4 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::FutureExt; use futures::FutureExt;
@@ -6,7 +8,10 @@ use galaxy_cli::agent::Harness;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity}; use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use shell_words::split as split_shell_words; use shell_words::split as split_shell_words;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput}; use super::{
child_agent_delegation_denial_reason, compose_leaf_agent_prompt, ActionExecution,
AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType, AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
@@ -27,10 +32,38 @@ pub enum StartAgentOutcome {
agent_id: String, agent_id: String,
output: String, output: String,
}, },
/// An error occurred while starting the agent. /// An error occurred while starting or running the agent.
Error(String), Error(String),
} }
/// Determines whether a dispatch receiver acknowledges startup or waits for a
/// direct-provider child to reach a terminal state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartAgentWaitPolicy {
Startup,
Completion,
}
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
match mode {
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
}
}
pub struct StartAgentDispatch {
pub request_id: StartAgentRequestId,
pub receiver: async_channel::Receiver<StartAgentOutcome>,
pub wait_policy: StartAgentWaitPolicy,
pub(super) detached: Arc<AtomicBool>,
}
impl StartAgentDispatch {
pub(super) fn mark_detached(&self) {
self.detached.store(true, Ordering::Release);
}
}
fn invalid_local_child_harness_error(harness_type: &str) -> String { fn invalid_local_child_harness_error(harness_type: &str) -> String {
let harness_name = harness_type.trim(); let harness_name = harness_type.trim();
if harness_name.is_empty() { if harness_name.is_empty() {
@@ -115,17 +148,19 @@ pub struct StartAgentRequest {
} }
struct PendingStartAgent { struct PendingStartAgent {
/// Present for standalone StartAgent tool calls. RunAgents dispatches use action_id: AIAgentActionId,
/// the same executor but do not have a one-to-one StartAgent action card. /// Present when RunAgents owns this dispatch. Standalone StartAgent calls
action_id: Option<AIAgentActionId>, /// use the action id only for their one-to-one inline child panel.
run_agents_child_name: Option<String>,
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
/// Set once the child conversation is synchronously created. /// Set once the child conversation is synchronously created.
child_conversation_id: Option<AIConversationId>, child_conversation_id: Option<AIConversationId>,
sender: async_channel::Sender<StartAgentOutcome>, sender: async_channel::Sender<StartAgentOutcome>,
detached: Arc<AtomicBool>,
/// Direct Bedrock/OpenAI parents do not have a server run id or an /// Direct Bedrock/OpenAI parents do not have a server run id or an
/// orchestration event stream. Keep the tool call open until their local /// orchestration event stream. Keep the tool call open until their local
/// child finishes, then return the child's output inline. /// child finishes, then return the child's output inline.
wait_for_completion: bool, wait_policy: StartAgentWaitPolicy,
} }
pub struct StartAgentExecutor { pub struct StartAgentExecutor {
@@ -158,34 +193,41 @@ impl StartAgentExecutor {
child_conversation_id: AIConversationId, child_conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let direct_provider_panel_link = { let child_link_event = {
let Some(pending) = self.pending.get_mut(&request_id) else { let Some(pending) = self.pending.get(&request_id) else {
return; return;
}; };
if pending.detached.load(Ordering::Acquire) {
self.pending.remove(&request_id);
return;
}
let pending = self
.pending
.get_mut(&request_id)
.expect("pending request was checked above");
pending.child_conversation_id = Some(child_conversation_id); pending.child_conversation_id = Some(child_conversation_id);
if pending.wait_for_completion { if let Some(agent_name) = pending.run_agents_child_name.clone() {
pending.action_id.clone().map(|action_id| { Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
( action_id: pending.action_id.clone(),
action_id, agent_name,
pending.parent_conversation_id, parent_conversation_id: pending.parent_conversation_id,
child_conversation_id, child_conversation_id,
)
}) })
} else if matches!(pending.wait_policy, StartAgentWaitPolicy::Completion) {
Some(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id: pending.action_id.clone(),
parent_conversation_id: pending.parent_conversation_id,
child_conversation_id,
},
)
} else { } else {
None None
} }
}; };
if let Some((action_id, parent_conversation_id, child_conversation_id)) = if let Some(event) = child_link_event {
direct_provider_panel_link ctx.emit(event);
{
ctx.emit(
StartAgentExecutorEvent::DirectProviderChildConversationCreated {
action_id,
parent_conversation_id,
child_conversation_id,
},
);
} }
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx); self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
} }
@@ -271,14 +313,15 @@ impl StartAgentExecutor {
return; return;
}; };
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg)); let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
// A child that reaches `complete_pending_as_error` never obtained an // Only startup acknowledgements may clean up a conversation that never
// agent id, so it failed at the launch stage. Clean up its hidden // initialized. Direct-provider completion waits preserve the terminal
// pane + conversation so the orchestration pill bar does not retain a // child so its transcript and failure remain inspectable.
// dead chip — but only for terminal failures, leaving recoverable let should_cleanup = matches!(pending.wait_policy, StartAgentWaitPolicy::Startup)
// `Blocked` startup states (e.g. awaiting GitHub auth) intact. && BlocklistAIHistoryModel::as_ref(ctx)
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx) .conversation(&child_conversation_id)
.conversation(&child_conversation_id) .is_some_and(|conversation| {
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status())); should_cleanup_failed_child_launch(conversation.status())
});
if should_cleanup { if should_cleanup {
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch { ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
conversation_id: child_conversation_id, conversation_id: child_conversation_id,
@@ -297,23 +340,49 @@ impl StartAgentExecutor {
else { else {
return; return;
}; };
if let Some(error_msg) = start_agent_error_message_for_status( let wait_policy = self
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
return;
}
let wait_for_completion = self
.pending .pending
.get(&request_id) .get(&request_id)
.is_some_and(|pending| pending.wait_for_completion); .map(|pending| pending.wait_policy);
if wait_for_completion && matches!(conversation.status(), ConversationStatus::Success) { match wait_policy {
self.complete_pending_as_completed(request_id, child_conversation_id, ctx); Some(StartAgentWaitPolicy::Completion) => match conversation.status() {
return; ConversationStatus::Success => {
} self.complete_pending_as_completed(request_id, child_conversation_id, ctx);
if conversation.orchestration_agent_id().is_some() { }
self.complete_pending_as_started(request_id, child_conversation_id, ctx); ConversationStatus::Error | ConversationStatus::Cancelled => {
let error_msg = direct_child_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
)
.expect("terminal direct child status should produce an error");
self.complete_pending_as_error(
request_id,
child_conversation_id,
error_msg,
ctx,
);
}
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => {}
},
Some(StartAgentWaitPolicy::Startup) => {
if let Some(error_msg) = start_agent_startup_error_message_for_status(
conversation.status(),
conversation.status_error_message().as_deref(),
) {
self.complete_pending_as_error(
request_id,
child_conversation_id,
error_msg,
ctx,
);
} else if conversation.orchestration_agent_id().is_some() {
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
}
}
None => {}
} }
} }
@@ -346,6 +415,22 @@ impl StartAgentExecutor {
} => { } => {
self.record_child_conversation(*request_id, *conversation_id, ctx); self.record_child_conversation(*request_id, *conversation_id, ctx);
} }
BlocklistAIHistoryEvent::RemoveConversation {
conversation_id, ..
}
| BlocklistAIHistoryEvent::DeletedConversation {
conversation_id, ..
} => {
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
return;
};
let Some(pending) = self.pending.remove(&request_id) else {
return;
};
let _ = pending.sender.try_send(StartAgentOutcome::Error(
"Child agent conversation was removed by the user.".to_string(),
));
}
BlocklistAIHistoryEvent::StartedNewConversation { .. } BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::CreatedSubtask { .. } | BlocklistAIHistoryEvent::CreatedSubtask { .. }
| BlocklistAIHistoryEvent::UpgradedTask { .. } | BlocklistAIHistoryEvent::UpgradedTask { .. }
@@ -358,8 +443,6 @@ impl StartAgentExecutor {
| BlocklistAIHistoryEvent::UpdatedTodoList { .. } | BlocklistAIHistoryEvent::UpdatedTodoList { .. }
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. } | BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RemoveConversation { .. }
| BlocklistAIHistoryEvent::DeletedConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. } | BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
@@ -400,12 +483,19 @@ impl StartAgentExecutor {
return ActionExecution::InvalidAction; return ActionExecution::InvalidAction;
}; };
let prompt = prompt.clone();
let version = *version; let version = *version;
let action_id = input.action.id.clone();
let parent_conversation_id = input.conversation_id; let parent_conversation_id = input.conversation_id;
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, version },
));
}
let prompt = prompt.clone();
let action_id = input.action.id.clone();
let (prompt, execution_mode) = let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone()); normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
let prompt = compose_leaf_agent_prompt(&prompt);
let (execution_mode, parent_run_id) = match execution_mode { let (execution_mode, parent_run_id) = match execution_mode {
StartAgentExecutionMode::Local { StartAgentExecutionMode::Local {
harness_type: None, harness_type: None,
@@ -531,20 +621,23 @@ impl StartAgentExecutor {
} }
}; };
// In local mode (no parent_run_id), block until the child finishes // Local children return their completed work; remote children acknowledge startup and
// so the parent model receives the child's output as the tool result. // continue through the hosted orchestration lifecycle.
let wait_for_completion = parent_run_id.is_none(); let wait_policy = wait_policy_for_execution_mode(&execution_mode);
let (sender, receiver) = async_channel::bounded(1); let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id(); let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert( self.pending.insert(
request_id, request_id,
PendingStartAgent { PendingStartAgent {
action_id: Some(action_id), action_id,
run_agents_child_name: None,
parent_conversation_id, parent_conversation_id,
child_conversation_id: None, child_conversation_id: None,
sender, sender,
wait_for_completion, detached,
wait_policy,
}, },
); );
@@ -589,6 +682,7 @@ impl StartAgentExecutor {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn dispatch( pub fn dispatch(
&mut self, &mut self,
action_id: AIAgentActionId,
name: String, name: String,
prompt: String, prompt: String,
execution_mode: StartAgentExecutionMode, execution_mode: StartAgentExecutionMode,
@@ -596,19 +690,34 @@ impl StartAgentExecutor {
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
parent_run_id: Option<String>, parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<StartAgentOutcome> { ) -> StartAgentDispatch {
let (prompt, execution_mode) = let wait_policy = wait_policy_for_execution_mode(&execution_mode);
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let (sender, receiver) = async_channel::bounded(1); let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id(); let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
let _ = sender.try_send(StartAgentOutcome::Error(error));
return StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
};
}
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let prompt = compose_leaf_agent_prompt(&prompt);
self.pending.insert( self.pending.insert(
request_id, request_id,
PendingStartAgent { PendingStartAgent {
action_id: None, action_id,
run_agents_child_name: Some(name.clone()),
parent_conversation_id, parent_conversation_id,
child_conversation_id: None, child_conversation_id: None,
sender, sender,
wait_for_completion: parent_run_id.is_none(), detached: detached.clone(),
wait_policy,
}, },
); );
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new( ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
@@ -622,7 +731,92 @@ impl StartAgentExecutor {
parent_run_id, parent_run_id,
}, },
))); )));
receiver StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
pub fn reattach(
&mut self,
action_id: AIAgentActionId,
name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
wait_policy: StartAgentWaitPolicy,
ctx: &mut ModelContext<Self>,
) -> StartAgentDispatch {
let (sender, receiver) = async_channel::bounded(1);
let request_id = self.next_request_id();
let detached = Arc::new(AtomicBool::new(false));
self.pending.insert(
request_id,
PendingStartAgent {
action_id,
run_agents_child_name: Some(name),
parent_conversation_id,
child_conversation_id: Some(child_conversation_id),
sender,
detached: detached.clone(),
wait_policy,
},
);
self.record_child_conversation(request_id, child_conversation_id, ctx);
StartAgentDispatch {
request_id,
receiver,
wait_policy,
detached,
}
}
/// Detaches one exact dispatch. If its launch callback is already queued,
/// the shared marker prevents that callback from linking a late child.
pub fn detach_dispatch(&mut self, request_id: StartAgentRequestId) -> bool {
let Some(pending) = self.pending.remove(&request_id) else {
return false;
};
pending.detached.store(true, Ordering::Release);
true
}
/// Test-only lookup for request ownership without exposing executor internals.
#[cfg(test)]
pub fn has_pending_dispatch_for_test(&self, request_id: StartAgentRequestId) -> bool {
self.pending.contains_key(&request_id)
}
pub fn cancel_dispatches_for_action(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> usize {
let request_ids = self
.pending
.iter()
.filter_map(|(request_id, pending)| {
(pending.parent_conversation_id == conversation_id
&& &pending.action_id == action_id)
.then_some(*request_id)
})
.collect::<Vec<_>>();
let detached_count = request_ids.len();
for request_id in request_ids {
self.detach_dispatch(request_id);
}
detached_count
}
/// Cancels only the caller's pending tool wait. A child that was already created keeps
/// running independently and remains available in conversation history.
pub(super) fn cancel_execution(
&mut self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
self.cancel_dispatches_for_action(conversation_id, action_id);
} }
pub(super) fn preprocess_action( pub(super) fn preprocess_action(
@@ -666,7 +860,7 @@ fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
} }
} }
fn start_agent_error_message_for_status( fn start_agent_startup_error_message_for_status(
status: &ConversationStatus, status: &ConversationStatus,
error_message: Option<&str>, error_message: Option<&str>,
) -> Option<String> { ) -> Option<String> {
@@ -701,6 +895,26 @@ fn start_agent_error_message_for_status(
} }
} }
fn direct_child_error_message_for_status(
status: &ConversationStatus,
error_message: Option<&str>,
) -> Option<String> {
match status {
ConversationStatus::Error => Some(
error_message
.filter(|message| !message.trim().is_empty())
.unwrap_or("Child agent failed")
.to_string(),
),
ConversationStatus::Cancelled => Some("Child agent was cancelled by the user.".to_string()),
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::Success
| ConversationStatus::Blocked { .. }
| ConversationStatus::WaitingForEvents => None,
}
}
impl Entity for StartAgentExecutor { impl Entity for StartAgentExecutor {
type Event = StartAgentExecutorEvent; type Event = StartAgentExecutorEvent;
} }
@@ -715,6 +929,14 @@ pub enum StartAgentExecutorEvent {
parent_conversation_id: AIConversationId, parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId, child_conversation_id: AIConversationId,
}, },
/// A RunAgents child conversation is available for live status and
/// navigation in the owning action card.
RunAgentsChildConversationCreated {
action_id: AIAgentActionId,
agent_name: String,
parent_conversation_id: AIConversationId,
child_conversation_id: AIConversationId,
},
/// A child agent failed at the launch stage (never started a server-side /// A child agent failed at the launch stage (never started a server-side
/// run). The owning terminal view removes its hidden pane and conversation /// run). The owning terminal view removes its hidden pane and conversation
/// so the orchestration pill bar does not retain a dead chip. /// so the orchestration pill bar does not retain a dead chip.
@@ -28,6 +28,37 @@ impl Entity for CapturedDirectProviderChildLinks {
type Event = (); type Event = ();
} }
#[derive(Default)]
struct CapturedStartAgentPrompts(Vec<String>);
impl Entity for CapturedStartAgentPrompts {
type Event = ();
}
#[derive(Default)]
struct CapturedRunAgentsChildLinks(
Vec<(AIAgentActionId, String, AIConversationId, AIConversationId)>,
);
impl Entity for CapturedRunAgentsChildLinks {
type Event = ();
}
fn capture_start_agent_prompts(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedStartAgentPrompts> {
let captured = app.add_model(|_| CapturedStartAgentPrompts::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CreateAgent(request) = event {
captured.0.push(request.prompt.clone());
}
});
});
captured
}
fn capture_direct_provider_child_links( fn capture_direct_provider_child_links(
app: &mut App, app: &mut App,
executor: &ModelHandle<StartAgentExecutor>, executor: &ModelHandle<StartAgentExecutor>,
@@ -52,6 +83,32 @@ fn capture_direct_provider_child_links(
captured captured
} }
fn capture_run_agents_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedRunAgentsChildLinks> {
let captured = app.add_model(|_| CapturedRunAgentsChildLinks::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::RunAgentsChildConversationCreated {
action_id,
agent_name,
parent_conversation_id,
child_conversation_id,
} = event
{
captured.0.push((
action_id.clone(),
agent_name.clone(),
*parent_conversation_id,
*child_conversation_id,
));
}
});
});
captured
}
fn build_start_agent_action( fn build_start_agent_action(
version: StartAgentVersion, version: StartAgentVersion,
execution_mode: StartAgentExecutionMode, execution_mode: StartAgentExecutionMode,
@@ -79,6 +136,192 @@ fn build_start_agent_action_with_prompt(
} }
} }
#[test]
fn execute_wraps_child_prompt_with_leaf_worker_contract() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = capture_start_agent_prompts(&mut app, &executor);
let root_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: root_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(execution, AnyActionExecution::Async { .. }));
captured.read(&app, |captured, _ctx| {
assert_eq!(captured.0.len(), 1);
assert!(captured.0[0].contains("You are a leaf worker"));
assert!(
captured.0[0].contains("Do not launch, delegate to, or create additional agents")
);
assert!(captured.0[0].ends_with("Assigned task:\nInvestigate the failure"));
});
});
}
#[test]
fn execute_denies_start_agent_from_child_conversation() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: child_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, .. }
)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn dispatch_denies_child_conversation_defense_in_depth() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents-action".to_string()),
"grandchild".to_string(),
"Do more work".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
child_conversation_id,
None,
ctx,
)
});
assert!(matches!(
dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn local_execution_waits_for_completion() {
assert_eq!(
wait_policy_for_execution_mode(&StartAgentExecutionMode::local_with_defaults()),
StartAgentWaitPolicy::Completion
);
}
#[test]
fn detach_dispatch_rejects_late_child_callback() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
AIAgentActionId::from("run-agents".to_string()),
"child".to_string(),
"work".to_string(),
StartAgentExecutionMode::Remote {
environment_id: "environment".to_string(),
skill_references: Vec::new(),
model_id: "model".to_string(),
computer_use_enabled: false,
worker_host: String::new(),
harness_type: "oz".to_string(),
title: String::new(),
auth_secret_name: None,
},
None,
parent_conversation_id,
Some(PARENT_RUN_ID.to_string()),
ctx,
)
});
assert!(executor.update(&mut app, |executor, _| {
executor.detach_dispatch(dispatch.request_id)
}));
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
dispatch.request_id,
child_conversation_id,
ctx,
);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
assert!(dispatch.receiver.try_recv().is_err());
});
}
#[test] #[test]
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() { fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command( let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
@@ -543,6 +786,426 @@ fn hosted_child_link_does_not_publish_direct_provider_panel_event() {
}); });
} }
struct PendingDirectProviderChild {
action_id: AIAgentActionId,
parent_conversation_id: AIConversationId,
history_model: ModelHandle<BlocklistAIHistoryModel>,
executor: ModelHandle<StartAgentExecutor>,
captured_cleanup: ModelHandle<CapturedCleanupEvents>,
direct_links: ModelHandle<CapturedDirectProviderChildLinks>,
run_agents_links: ModelHandle<CapturedRunAgentsChildLinks>,
terminal_view_id: EntityId,
child_conversation_id: AIConversationId,
dispatch: StartAgentDispatch,
}
fn dispatch_pending_direct_provider_child(app: &mut App) -> PendingDirectProviderChild {
initialize_history_persistence_for_tests(app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured_cleanup = app.add_model(|_| CapturedCleanupEvents::default());
captured_cleanup.update(app, |_, ctx| {
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
captured.0.push(*conversation_id);
}
});
});
let direct_links = capture_direct_provider_child_links(app, &executor);
let run_agents_links = capture_run_agents_child_links(app, &executor);
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action_id = AIAgentActionId::from("run-agents-action".to_string());
let dispatch = executor.update(app, |executor, ctx| {
executor.dispatch(
action_id.clone(),
"child".to_string(),
"Investigate the failure".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
parent_conversation_id,
None,
ctx,
)
});
let child_conversation_id = history_model.update(app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(app, |history_model, ctx| {
history_model.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
PendingDirectProviderChild {
action_id,
parent_conversation_id,
history_model,
executor,
captured_cleanup,
direct_links,
run_agents_links,
terminal_view_id,
child_conversation_id,
dispatch,
}
}
#[test]
fn direct_provider_nonterminal_states_remain_pending_until_cancelled() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
assert_eq!(state.dispatch.wait_policy, StartAgentWaitPolicy::Completion);
for status in [
ConversationStatus::Blocked {
blocked_action: "Waiting for user input".to_string(),
},
ConversationStatus::TransientError,
ConversationStatus::WaitingForEvents,
] {
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
status,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
});
}
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Cancelled,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent was cancelled by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
state.captured_cleanup.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
});
}
#[test]
fn direct_provider_error_preserves_child_for_inspection() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status_with_error(
state.terminal_view_id,
state.child_conversation_id,
ConversationStatus::Error,
Some(RenderableAIError::other("Child execution failed", false)),
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error == "Child execution failed"
));
state.captured_cleanup.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
state.history_model.read(&app, |history_model, _| {
assert!(history_model
.conversation(&state.child_conversation_id)
.is_some());
});
});
}
#[test]
fn cancelling_standalone_start_agent_drops_dispatch_but_keeps_child_running() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: parent_conversation_id,
},
ctx,
)
.into()
});
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
history_model.update(&mut app, |history, ctx| {
history.record_new_conversation_request_complete(
FIRST_REQUEST_ID,
child_conversation_id,
ctx,
);
});
executor.update(&mut app, |executor, _| {
executor.cancel_execution(parent_conversation_id, &action.id);
});
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
history_model.read(&app, |history, _| {
assert_eq!(
history
.conversation(&child_conversation_id)
.expect("child should remain in history")
.status(),
&ConversationStatus::InProgress
);
});
let AnyActionExecution::Async { execute_future, .. } = execution else {
panic!("expected async StartAgent execution");
};
let _ = execute_future.await;
});
}
#[test]
fn cancelling_duplicate_action_id_detaches_only_the_matching_conversation() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let terminal_view_id = EntityId::new();
let first_conversation = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let second_conversation = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let first = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: first_conversation,
},
ctx,
)
.into()
});
let second = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: second_conversation,
},
ctx,
)
.into()
});
assert!(matches!(first, AnyActionExecution::Async { .. }));
assert!(matches!(second, AnyActionExecution::Async { .. }));
executor.update(&mut app, |executor, _| {
executor.cancel_execution(first_conversation, &action.id);
assert_eq!(executor.pending.len(), 1);
assert_eq!(
executor
.pending
.values()
.next()
.unwrap()
.parent_conversation_id,
second_conversation
);
});
});
}
#[test]
fn removing_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.remove_conversation(
state.child_conversation_id,
state.terminal_view_id,
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent conversation was removed by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn deleting_direct_provider_child_resolves_pending_wait() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.history_model.update(&mut app, |history_model, ctx| {
history_model.delete_conversation(
state.child_conversation_id,
Some(state.terminal_view_id),
ctx,
);
});
assert!(matches!(
state.dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Error(error))
if error == "Child agent conversation was removed by the user."
));
state.executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn run_agents_dispatch_publishes_only_run_agents_child_link() {
App::test((), |mut app| async move {
let state = dispatch_pending_direct_provider_child(&mut app);
state.direct_links.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
state.run_agents_links.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
state.action_id.clone(),
"child".to_string(),
state.parent_conversation_id,
state.child_conversation_id,
)]
);
});
});
}
#[test]
fn reattach_reuses_persisted_child_without_launching_another_agent() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured_prompts = capture_start_agent_prompts(&mut app, &executor);
let captured_links = capture_run_agents_child_links(&mut app, &executor);
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
"child".to_string(),
parent_conversation_id,
None,
ctx,
)
});
let action_id = AIAgentActionId::from("run-agents-action".to_string());
let dispatch = executor.update(&mut app, |executor, ctx| {
executor.reattach(
action_id.clone(),
"child".to_string(),
parent_conversation_id,
child_conversation_id,
StartAgentWaitPolicy::Completion,
ctx,
)
});
assert_eq!(dispatch.wait_policy, StartAgentWaitPolicy::Completion);
assert!(matches!(
dispatch.receiver.try_recv(),
Err(async_channel::TryRecvError::Empty)
));
captured_prompts.read(&app, |captured, _| {
assert!(captured.0.is_empty());
});
captured_links.read(&app, |captured, _| {
assert_eq!(
captured.0,
vec![(
action_id,
"child".to_string(),
parent_conversation_id,
child_conversation_id,
)]
);
});
history_model.update(&mut app, |history_model, ctx| {
history_model.update_conversation_status(
terminal_view_id,
child_conversation_id,
ConversationStatus::Success,
ctx,
);
});
assert!(matches!(
dispatch.receiver.try_recv(),
Ok(StartAgentOutcome::Completed { agent_id, .. })
if agent_id == child_conversation_id.to_string()
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_empty());
});
});
}
#[test] #[test]
fn execute_waits_for_direct_provider_child_and_returns_its_output() { fn execute_waits_for_direct_provider_child_and_returns_its_output() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -596,7 +1259,7 @@ fn execute_waits_for_direct_provider_child_and_returns_its_output() {
.pending .pending
.get(&FIRST_REQUEST_ID) .get(&FIRST_REQUEST_ID)
.expect("direct child should remain pending until completion"); .expect("direct child should remain pending until completion");
assert!(pending.wait_for_completion); assert_eq!(pending.wait_policy, StartAgentWaitPolicy::Completion);
}); });
history_model.update(&mut app, |history_model, ctx| { history_model.update(&mut app, |history_model, ctx| {
@@ -62,6 +62,9 @@ fn initialize_upload_artifact_test(
app.add_singleton_model(TeamTesterStatus::mock); app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock); app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock); app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default()); app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(UserWorkspaces::default_mock); app.add_singleton_model(UserWorkspaces::default_mock);
let profiles = app.add_singleton_model(|ctx| { let profiles = app.add_singleton_model(|ctx| {
+374 -3
View File
@@ -3,7 +3,10 @@ use std::sync::Arc;
use super::*; use super::*;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType; use crate::ai::agent::{
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
GrepResult, ReadFilesResult,
};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> { fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult { Arc::new(AIAgentActionResult {
@@ -13,6 +16,44 @@ fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
}) })
} }
fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResult {
AIAgentActionResult {
id: AIAgentActionId::from(id.to_owned()),
task_id: TaskId::new("task".to_owned()),
result,
}
}
fn action(id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_string()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new("task".to_string()),
requires_result: true,
tool_name: Some("init_project".to_string()),
}
}
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(7),
},
calls: call_ids
.iter()
.map(|call_id| galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: (*call_id).to_string(),
name: "init_project".to_string(),
arguments: serde_json::json!({}),
},
state: galaxy_agent_core::PendingToolCallState::Proposed,
})
.collect(),
}
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize { fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None; let mut current_phase = None;
let mut count = 0; let mut count = 0;
@@ -35,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
count count
} }
#[test]
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first", "second"]);
let actions = vec![action("first"), action("second")];
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
assert_eq!(correlations.len(), 2);
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
assert_eq!(correlations[0].1.call_id, "first");
let error = provider_action_correlations(
&[action("second"), action("first")],
conversation_id,
&batch,
)
.unwrap_err();
assert_eq!(
error,
ProviderActionQueueError::ActionSetMismatch {
expected: vec!["first".to_string(), "second".to_string()],
received: vec!["second".to_string(), "first".to_string()],
}
);
}
#[test] #[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() { fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase = let phase =
@@ -71,6 +140,47 @@ fn phased_scheduling_stops_at_serial_barrier_and_resumes_afterward() {
assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2); assert_eq!(count_startable_actions_for_pass(&actions[3..]), 2);
} }
#[test]
fn automatic_retries_only_target_actions_deferred_as_not_ready() {
let conversation_id = AIConversationId::new();
let action_id = AIAgentActionId::from("file-edit".to_string());
let mut tracker = NotReadyActionTracker::default();
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NotReady,
ActionExecutionInitiator::Automatic,
);
assert!(tracker.should_retry(conversation_id, &action_id));
assert!(!ActionExecutionInitiator::Automatic.is_user_initiated());
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NotReady,
ActionExecutionInitiator::User,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::NeedsConfirmation,
ActionExecutionInitiator::Automatic,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
tracker.update_after_attempt(
conversation_id,
action_id.clone(),
NotExecutedReason::WaitingOnSharer,
ActionExecutionInitiator::Automatic,
);
assert!(!tracker.should_retry(conversation_id, &action_id));
assert!(ActionExecutionInitiator::User.is_user_initiated());
}
#[test] #[test]
fn finished_results_stay_in_original_action_order() { fn finished_results_stay_in_original_action_order() {
let action_order = HashMap::from([ let action_order = HashMap::from([
@@ -84,8 +194,7 @@ fn finished_results_stay_in_original_action_order() {
make_action_result("second"), make_action_result("second"),
]; ];
finished_results sort_action_results_by_order(&mut finished_results, &action_order);
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
assert_eq!( assert_eq!(
finished_results[0].id, finished_results[0].id,
@@ -100,3 +209,265 @@ fn finished_results_stay_in_original_action_order() {
AIAgentActionId::from("third".to_owned()) AIAgentActionId::from("third".to_owned())
); );
} }
#[test]
fn domain_tool_results_preserve_success_failure_cancellation_and_denial() {
let success = domain_tool_result(
&action_result("success", AIAgentActionResultType::InitProject),
false,
);
let failure = domain_tool_result(
&action_result(
"failure",
AIAgentActionResultType::Grep(GrepResult::Error("boom".to_string())),
),
false,
);
let cancelled_result = action_result(
"cancelled",
AIAgentActionResultType::Grep(GrepResult::Cancelled),
);
let cancelled = domain_tool_result(&cancelled_result, false);
let denied = domain_tool_result(&cancelled_result, true);
assert_eq!(success.status, ToolResultStatus::Success);
assert_eq!(failure.status, ToolResultStatus::Error);
assert_eq!(cancelled.status, ToolResultStatus::Cancelled);
assert_eq!(denied.status, ToolResultStatus::Denied);
assert_eq!(success.call_id, "success");
assert_eq!(failure.call_id, "failure");
assert_eq!(cancelled.call_id, "cancelled");
assert_eq!(denied.call_id, "cancelled");
assert!(!cancelled.content.contains("Permission denied"));
assert!(denied.content.contains("Permission denied by the user"));
}
#[test]
fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
let result = action_result(
"read-call",
AIAgentActionResultType::ReadFiles(ReadFilesResult::Success {
files: vec![FileContext::new(
"/workspace/src/lib.rs".to_string(),
AnyFileContent::StringContent("pub fn answer() -> u8 { 42 }".to_string()),
None,
None,
)],
}),
);
let result = domain_tool_result(&result, false);
assert_eq!(result.status, ToolResultStatus::Success);
assert_eq!(result.call_id, "read-call");
assert!(result.content.contains("/workspace/src/lib.rs"));
assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
}
#[test]
fn action_permission_kinds_match_the_safety_boundary() {
assert_eq!(
permission_kind_for_action(&AIAgentActionType::Grep {
queries: vec!["needle".to_string()],
path: ".".to_string(),
}),
PermissionKind::Read
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::InitProject),
PermissionKind::Write
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::RequestCommandOutput {
command: "cargo test".to_string(),
is_read_only: Some(true),
is_risky: Some(false),
wait_until_completion: true,
uses_pager: Some(false),
rationale: None,
citations: Vec::new(),
}),
PermissionKind::Execute
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::CallMCPTool {
server_id: None,
name: "tool".to_string(),
input: serde_json::json!({}),
}),
PermissionKind::ExternalTool
);
}
#[test]
fn denied_permission_event_resolves_the_pending_call() {
let action = action("call-1");
let ToolEvent::PermissionResolved {
request_id,
call_id,
decision,
} = permission_denied_tool_event(&action)
else {
panic!("expected a permission resolution event");
};
assert_eq!(request_id, "permission:call-1");
assert_eq!(call_id, "call-1");
assert_eq!(
decision,
PermissionDecision::Denied {
reason: Some("Permission denied by the user.".to_string()),
}
);
}
#[test]
fn provider_owned_denial_suppresses_duplicate_completion() {
assert!(!should_emit_tool_completion(true, true));
assert!(should_emit_tool_completion(true, false));
assert!(should_emit_tool_completion(false, true));
}
#[test]
fn only_rejecting_a_blocked_action_is_a_permission_denial() {
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Blocked),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Queued),
));
assert!(!is_permission_denial(
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
Some(&AIActionStatus::Blocked),
));
}
#[test]
fn duplicate_action_ids_resolve_only_within_the_requested_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
let first_result = make_action_result("duplicate");
let mut second_result = action_result("duplicate", AIAgentActionResultType::InitProject);
second_result.task_id = TaskId::new("second-task".to_string());
let second_result = Arc::new(second_result);
let finished_results = HashMap::from([(first_conversation, vec![first_result.clone()])]);
let provider_results = HashMap::new();
let archive = HashMap::from([
(
(first_conversation, duplicate_id.clone()),
first_result.clone(),
),
(
(second_conversation, duplicate_id.clone()),
second_result.clone(),
),
]);
assert!(Arc::ptr_eq(
action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
first_conversation,
&duplicate_id,
)
.unwrap(),
&first_result,
));
assert!(Arc::ptr_eq(
action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
second_conversation,
&duplicate_id,
)
.unwrap(),
&second_result,
));
assert!(action_result_for_conversation(
&finished_results,
&provider_results,
&archive,
AIConversationId::new(),
&duplicate_id,
)
.is_none());
}
#[test]
fn cancellation_permission_inference_uses_the_matching_conversation_status() {
let blocked_conversation = AIConversationId::new();
let queued_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_string());
let pending_actions = HashMap::from([
(blocked_conversation, VecDeque::from([action("duplicate")])),
(
queued_conversation,
VecDeque::from([action("first"), action("duplicate")]),
),
]);
let running_actions = HashMap::new();
let blocked_status = pending_action_status(
&pending_actions,
&running_actions,
blocked_conversation,
&duplicate_id,
false,
);
let queued_status = pending_action_status(
&pending_actions,
&running_actions,
queued_conversation,
&duplicate_id,
false,
);
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
blocked_status.as_ref(),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
queued_status.as_ref(),
));
}
#[test]
fn action_lifecycle_events_disambiguate_duplicate_ids_by_conversation() {
let first_conversation = AIConversationId::new();
let second_conversation = AIConversationId::new();
let duplicate_id = AIAgentActionId::from("duplicate".to_owned());
let events = [
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id: duplicate_id.clone(),
conversation_id: first_conversation,
execution_ref: None,
},
BlocklistAIActionEvent::ExecutingAction {
action_id: duplicate_id.clone(),
conversation_id: second_conversation,
execution_ref: None,
},
BlocklistAIActionEvent::FinishedAction {
action_id: duplicate_id.clone(),
conversation_id: first_conversation,
cancellation_reason: None,
execution_ref: None,
},
];
assert_eq!(events[0].conversation_id(), Some(first_conversation));
assert_eq!(events[1].conversation_id(), Some(second_conversation));
assert_eq!(events[2].conversation_id(), Some(first_conversation));
assert!(events
.iter()
.all(|event| event.action_id() == &duplicate_id));
}
@@ -1405,6 +1405,7 @@ impl AgentInputFooter {
) -> Option<Box<dyn Element>> { ) -> Option<Box<dyn Element>> {
if !item.available_in().is_available_for_cli() if !item.available_in().is_available_for_cli()
|| !item.available_to_session_viewer(shared_status, false) || !item.available_to_session_viewer(shared_status, false)
|| !item.is_available(app)
{ {
return None; return None;
} }
@@ -2016,6 +2017,7 @@ impl AgentInputFooter {
}); });
if !item.available_in().is_available_for_agent_view() if !item.available_in().is_available_for_agent_view()
|| !item.available_to_session_viewer(shared_status, is_cloud_mode) || !item.available_to_session_viewer(shared_status, is_cloud_mode)
|| !item.is_available(app)
{ {
return None; return None;
} }
@@ -178,6 +178,8 @@ impl AgentToolbarItemKind {
pub fn is_available(&self, app: &warpui::AppContext) -> bool { pub fn is_available(&self, app: &warpui::AppContext) -> bool {
match self { match self {
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app), Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
// Retain the enum variant so existing toolbar settings still deserialize.
Self::ShareSession => false,
_ => true, _ => true,
} }
} }
@@ -215,11 +217,6 @@ impl AgentToolbarItemKind {
Self::ContextWindowUsage, Self::ContextWindowUsage,
Self::ModelSelector, Self::ModelSelector,
]; ];
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled() if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled() && FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm"))) && cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -247,11 +244,6 @@ impl AgentToolbarItemKind {
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() { if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
items.push(Self::FastForwardToggle); items.push(Self::FastForwardToggle);
} }
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& FeatureFlag::HOARemoteControl.is_enabled()
{
items.push(Self::ShareSession);
}
if FeatureFlag::OzHandoff.is_enabled() if FeatureFlag::OzHandoff.is_enabled()
&& FeatureFlag::HandoffLocalCloud.is_enabled() && FeatureFlag::HandoffLocalCloud.is_enabled()
&& cfg!(all(feature = "local_fs", not(target_family = "wasm"))) && cfg!(all(feature = "local_fs", not(target_family = "wasm")))
@@ -322,3 +314,7 @@ impl From<ContextChipKind> for AgentToolbarItemKind {
Self::ContextChip(kind) Self::ContextChip(kind)
} }
} }
#[cfg(test)]
#[path = "toolbar_item_tests.rs"]
mod tests;
@@ -0,0 +1,15 @@
use super::AgentToolbarItemKind;
#[test]
fn legacy_share_session_setting_remains_deserializable() {
let item: AgentToolbarItemKind =
serde_json::from_str("\"ShareSession\"").expect("legacy setting should deserialize");
assert_eq!(item, AgentToolbarItemKind::ShareSession);
}
#[test]
fn share_session_is_not_offered_by_defaults_or_configurator() {
assert!(!AgentToolbarItemKind::default_right().contains(&AgentToolbarItemKind::ShareSession));
assert!(!AgentToolbarItemKind::all_available().contains(&AgentToolbarItemKind::ShareSession));
}
+153 -39
View File
@@ -30,6 +30,7 @@ use base64::Engine as _;
use chrono::Duration; use chrono::Duration;
use cli_controller::{CLISubagentController, CLISubagentEvent}; use cli_controller::{CLISubagentController, CLISubagentEvent};
use find::FindState; use find::FindState;
use galaxy_agent_core::RuntimeActivityStatus;
use galaxy_core::features::FeatureFlag; use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill; use galaxy_core::ui::theme::Fill;
@@ -818,6 +819,27 @@ impl CollapsibleElementState {
} }
} }
fn sync_runtime_activity(&mut self, is_streaming: bool, is_finished: bool, has_output: bool) {
if is_streaming
&& has_output
&& !self.user_toggled_while_streaming
&& matches!(self.expansion_state, CollapsibleExpansionState::Collapsed)
{
self.expand();
}
self.sync_finished_state(is_finished);
if is_finished {
if let CollapsibleExpansionState::Expanded {
scroll_pinned_to_bottom,
..
} = &mut self.expansion_state
{
*scroll_pinned_to_bottom = false;
}
}
}
/// Applies orchestration message display behavior after streaming finishes. /// Applies orchestration message display behavior after streaming finishes.
fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) { fn finish_orchestration_message(&mut self, display_mode: OrchestrationMessageDisplayMode) {
let should_auto_collapse = self.should_auto_collapse_on_finish(); let should_auto_collapse = self.should_auto_collapse_on_finish();
@@ -2174,6 +2196,19 @@ impl AIBlock {
} }
match action { match action {
AIAgentAction {
id: action_id,
action: AIAgentActionType::RequestFileEdits { title, file_edits },
..
} => {
self.ensure_requested_edit_view(
action_id,
title,
file_edits.clone(),
output.server_output_id.clone(),
ctx,
);
}
AIAgentAction { AIAgentAction {
id: action_id, id: action_id,
action: action:
@@ -2323,6 +2358,32 @@ impl AIBlock {
// Register element state for reasoning messages and track summarization timing. // Register element state for reasoning messages and track summarization timing.
for message in &output.messages { for message in &output.messages {
if let AIAgentOutputMessageType::RuntimeActivity(activity) = &message.message {
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
let is_finished = matches!(
activity.status,
Some(RuntimeActivityStatus::Completed | RuntimeActivityStatus::Failed)
);
let has_output = activity
.output
.as_deref()
.is_some_and(|output| !output.is_empty());
let state = self
.collapsible_block_states
.entry(message.id.clone())
.or_insert_with(|| {
if is_streaming && has_output {
CollapsibleElementState::default()
} else {
CollapsibleElementState::collapsed()
}
});
state.sync_runtime_activity(is_streaming, is_finished, has_output);
}
if let AIAgentOutputMessageType::Reasoning { if let AIAgentOutputMessageType::Reasoning {
finished_duration, .. finished_duration, ..
} = &message.message } = &message.message
@@ -2608,6 +2669,7 @@ impl AIBlock {
| AIAgentOutputMessageType::Reasoning { .. } | AIAgentOutputMessageType::Reasoning { .. }
| AIAgentOutputMessageType::Summarization { .. } | AIAgentOutputMessageType::Summarization { .. }
| AIAgentOutputMessageType::Subagent(_) | AIAgentOutputMessageType::Subagent(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::TodoOperation(_) | AIAgentOutputMessageType::TodoOperation(_)
| AIAgentOutputMessageType::WebSearch(_) | AIAgentOutputMessageType::WebSearch(_)
@@ -2712,7 +2774,7 @@ impl AIBlock {
}, },
.. ..
} => { } => {
self.handle_requested_edit_complete( self.ensure_requested_edit_view(
id, id,
title, title,
file_edits.clone(), file_edits.clone(),
@@ -3232,7 +3294,7 @@ impl AIBlock {
}); });
} }
fn handle_requested_edit_complete( fn ensure_requested_edit_view(
&mut self, &mut self,
action_id: &AIAgentActionId, action_id: &AIAgentActionId,
title: &Option<String>, title: &Option<String>,
@@ -3240,6 +3302,10 @@ impl AIBlock {
server_output_id: Option<ServerOutputId>, server_output_id: Option<ServerOutputId>,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
if self.requested_edits.contains_key(action_id) {
return;
}
let identifiers = AIIdentifiers { let identifiers = AIIdentifiers {
client_conversation_id: Some(self.client_ids.conversation_id), client_conversation_id: Some(self.client_ids.conversation_id),
client_exchange_id: Some(self.client_ids.client_exchange_id), client_exchange_id: Some(self.client_ids.client_exchange_id),
@@ -3295,14 +3361,6 @@ impl AIBlock {
ctx, ctx,
) )
}); });
let executor = self
.action_model
.as_ref(ctx)
.request_file_edits_executor(ctx);
executor.update(ctx, |executor, _| {
executor.register_requested_edits(action_id, &view);
});
// If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload. // If the diff is being viewed in a shared session (read-only mode), populate diffs from the payload.
if self.action_model.as_ref(ctx).is_view_only() { if self.action_model.as_ref(ctx).is_view_only() {
let active_session = self.active_session.as_ref(ctx); let active_session = self.active_session.as_ref(ctx);
@@ -3459,7 +3517,17 @@ impl AIBlock {
}); });
self.requested_edits self.requested_edits
.insert(action_id.clone(), RequestedEdit::new(view)); .insert(action_id.clone(), RequestedEdit::new(view.clone()));
let executor = self
.action_model
.as_ref(ctx)
.request_file_edits_executor(ctx);
executor.update(ctx, |executor, ctx| {
executor.register_requested_edits(action_id, &view, ctx);
});
self.action_model.update(ctx, |action_model, ctx| {
action_model.retry_not_ready_action(action_id, self.client_ids.conversation_id, ctx);
});
if self.model.request_type(ctx).is_passive() { if self.model.request_type(ctx).is_passive() {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
@@ -3510,7 +3578,10 @@ impl AIBlock {
} }
// Set the state based on the action status from the action model // Set the state based on the action status from the action model
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id); let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let is_reverted = BlocklistAIHistoryModel::as_ref(ctx) let is_reverted = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&self.client_ids.conversation_id) .conversation(&self.client_ids.conversation_id)
@@ -3605,6 +3676,7 @@ impl AIBlock {
RequestedCommandViewEvent::Accepted => { RequestedCommandViewEvent::Accepted => {
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted( action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
action_id, action_id,
view.as_ref(ctx).command_text().to_string(), view.as_ref(ctx).command_text().to_string(),
ctx, ctx,
@@ -3623,7 +3695,10 @@ impl AIBlock {
RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => { RequestedCommandViewEvent::UpdatedExpansionState { is_expanded } => {
// We only care about expansion state updates when the command // We only care about expansion state updates when the command
// is running or finished (i.e. when it has a block). // is running or finished (i.e. when it has a block).
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id); let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
let has_finished_command_block = { let has_finished_command_block = {
let terminal_model = self.terminal_model.lock(); let terminal_model = self.terminal_model.lock();
terminal_model terminal_model
@@ -3822,7 +3897,7 @@ impl AIBlock {
if self if self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_status(action_id) .get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_blocked()) .is_some_and(|status| status.is_blocked())
{ {
ctx.focus(&view); ctx.focus(&view);
@@ -4206,7 +4281,10 @@ impl AIBlock {
// but it's not incorrect to populate if it is, and we rely on this for // but it's not incorrect to populate if it is, and we rely on this for
// for restored conversations because action model events don't re-fire // for restored conversations because action model events don't re-fire
// after the view is created. // after the view is created.
let action_status = self.action_model.as_ref(ctx).get_action_status(action_id); let action_status = self
.action_model
.as_ref(ctx)
.get_action_status(self.client_ids.conversation_id, action_id);
if let Some(view) = self.search_codebase_view.get(action_id) { if let Some(view) = self.search_codebase_view.get(action_id) {
let files = if let Some(AIActionStatus::Finished(ref result)) = action_status { let files = if let Some(AIActionStatus::Finished(ref result)) = action_status {
if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success { if let AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Success {
@@ -4640,7 +4718,11 @@ impl AIBlock {
pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool { pub fn is_blocked_on_user_confirmation(&self, app: &AppContext) -> bool {
self.requested_action_ids self.requested_action_ids
.iter() .iter()
.filter_map(|id| self.action_model.as_ref(app).get_action_status(id)) .filter_map(|id| {
self.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, id)
})
.any(|status| status.is_blocked()) .any(|status| status.is_blocked())
} }
@@ -4666,7 +4748,12 @@ impl AIBlock {
ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| { ctx.subscribe_to_model(action_model, |me, action_model, event, ctx| {
let action_id = event.action_id(); let action_id = event.action_id();
if me.is_finished() || !me.requested_action_ids.contains(action_id) { if event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.client_ids.conversation_id)
|| me.is_finished()
|| !me.requested_action_ids.contains(action_id)
{
// Technically, this subscription should be unregistered after `is_finished` is // Technically, this subscription should be unregistered after `is_finished` is
// set to true, but it seems that the callback is called once more after the `unsubscribe_to_model` // set to true, but it seems that the callback is called once more after the `unsubscribe_to_model`
// call, so early return here if this is errantly being called. // call, so early return here if this is errantly being called.
@@ -4674,7 +4761,7 @@ impl AIBlock {
} }
match event { match event {
BlocklistAIActionEvent::ExecutingAction(..) => { BlocklistAIActionEvent::ExecutingAction { .. } => {
match &me.autonomy_setting_speedbump { match &me.autonomy_setting_speedbump {
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands { AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
action_id: speedbump_action_id, action_id: speedbump_action_id,
@@ -4744,7 +4831,7 @@ impl AIBlock {
_ => {} _ => {}
} }
} }
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation); ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
} }
BlocklistAIActionEvent::FinishedAction { action_id, .. } => { BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
@@ -4760,7 +4847,7 @@ impl AIBlock {
{ {
let should_collapse = action_model let should_collapse = action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(action_id) .get_action_result(me.client_ids.conversation_id, action_id)
.is_none_or(|result| match &result.result { .is_none_or(|result| match &result.result {
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::Completed { exit_code, .. }, RequestCommandOutputResult::Completed { exit_code, .. },
@@ -4775,7 +4862,9 @@ impl AIBlock {
} }
if let Some(view) = me.search_codebase_view.get(action_id) { if let Some(view) = me.search_codebase_view.get(action_id) {
let new_status = action_model.as_ref(ctx).get_action_status(action_id); let new_status = action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id);
view.update(ctx, |view, ctx| { view.update(ctx, |view, ctx| {
view.update_status(new_status); view.update_status(new_status);
ctx.notify(); ctx.notify();
@@ -4784,7 +4873,9 @@ impl AIBlock {
// Create subagent panel state for finished StartAgent actions // Create subagent panel state for finished StartAgent actions
if let Some(AIActionStatus::Finished(result)) = if let Some(AIActionStatus::Finished(result)) =
action_model.as_ref(ctx).get_action_status(action_id) action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id)
{ {
if let AIAgentActionResultType::StartAgent( if let AIAgentActionResultType::StartAgent(
crate::ai::agent::StartAgentResult::Success { agent_id, .. }, crate::ai::agent::StartAgentResult::Success { agent_id, .. },
@@ -4806,7 +4897,11 @@ impl AIBlock {
let action_statuses = me let action_statuses = me
.requested_action_ids .requested_action_ids
.iter() .iter()
.filter_map(|id| action_model.as_ref(ctx).get_action_status(id)) .filter_map(|id| {
action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, id)
})
.collect_vec(); .collect_vec();
// Detecting links on SearchCodebase tool call outputs // Detecting links on SearchCodebase tool call outputs
@@ -4839,7 +4934,9 @@ impl AIBlock {
view.update_render_read_file_args( view.update_render_read_file_args(
&me.find_state, &me.find_state,
files.clone(), files.clone(),
action_model.as_ref(ctx).get_action_status(action_id), action_model
.as_ref(ctx)
.get_action_status(me.client_ids.conversation_id, action_id),
); );
ctx.notify(); ctx.notify();
}) })
@@ -4849,7 +4946,9 @@ impl AIBlock {
// Open the AI document pane when documents are created or edited // Open the AI document pane when documents are created or edited
if let Some(action_result) = if let Some(action_result) =
action_model.as_ref(ctx).get_action_result(action_id) action_model
.as_ref(ctx)
.get_action_result(me.client_ids.conversation_id, action_id)
{ {
match &action_result.result { match &action_result.result {
AIAgentActionResultType::CreateDocuments( AIAgentActionResultType::CreateDocuments(
@@ -4901,7 +5000,7 @@ impl AIBlock {
} }
ctx.notify(); ctx.notify();
} }
BlocklistAIActionEvent::QueuedAction(action_id) => { BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
// Update search codebase view status when action is queued // Update search codebase view status when action is queued
if let Some(view) = me.search_codebase_view.get(action_id) { if let Some(view) = me.search_codebase_view.get(action_id) {
view.update(ctx, |view, ctx| { view.update(ctx, |view, ctx| {
@@ -4929,7 +5028,8 @@ impl AIBlock {
} }
} }
BlocklistAIActionEvent::InitProject(_) BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_) => {} | BlocklistAIActionEvent::ToggleCodeReview(_) => {}
} }
}); });
@@ -5608,7 +5708,9 @@ impl AIBlock {
/// This hides their keybindings in the UI and makes them less interactive. /// This hides their keybindings in the UI and makes them less interactive.
pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) { pub fn ignore_passive_actions(&mut self, ctx: &mut ViewContext<Self>) {
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
for action in action_model.get_pending_actions() { for action in
action_model.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
{
if let Some(edit) = self.requested_edits.get(&action.id) { if let Some(edit) = self.requested_edits.get(&action.id) {
edit.view.update(ctx, |view, ctx| view.dismiss(ctx)); edit.view.update(ctx, |view, ctx| view.dismiss(ctx));
} else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) { } else if let Some(suggested_prompt) = self.unit_tests_suggestions.get(&action.id) {
@@ -5661,7 +5763,12 @@ impl AIBlock {
.view .view
.update(ctx, |view, ctx| view.commit_and_get_command_text(ctx)); .update(ctx, |view, ctx| view.commit_and_get_command_text(ctx));
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
action_model.handle_requested_command_accepted(&action_id, command_text, ctx); action_model.handle_requested_command_accepted(
self.client_ids.conversation_id,
&action_id,
command_text,
ctx,
);
}); });
ctx.notify(); ctx.notify();
} }
@@ -5689,12 +5796,11 @@ impl AIBlock {
/// Finds the undismissed passive code diff across all pending actions. /// Finds the undismissed passive code diff across all pending actions.
/// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear. /// This is needed because passive code diffs are NOT added to the active conversation by default, when they first appear.
pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> { pub(crate) fn find_undismissed_code_diff(&self, app: &AppContext) -> Option<&RequestedEdit> {
let all_pending_actions = self.action_model.as_ref(app).get_pending_actions();
// Find any RequestFileEdits action that has a corresponding passive code diff view. // Find any RequestFileEdits action that has a corresponding passive code diff view.
// Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time. // Note that we only expect a maximum of 1 passive code diff to be undismissed at any given time.
all_pending_actions self.action_model
.iter() .as_ref(app)
.get_pending_actions_for_conversation(&self.client_ids.conversation_id)
.find_map(|action| match &action.action { .find_map(|action| match &action.action {
AIAgentActionType::RequestFileEdits { AIAgentActionType::RequestFileEdits {
file_edits: _, file_edits: _,
@@ -5734,7 +5840,10 @@ impl AIBlock {
.is_none_or(|output| { .is_none_or(|output| {
output.get().actions().last().is_none_or(|action| { output.get().actions().last().is_none_or(|action| {
let is_streaming = self.model.status(app).is_streaming(); let is_streaming = self.model.status(app).is_streaming();
let status = self.action_model.as_ref(app).get_action_status(&action.id); let status = self
.action_model
.as_ref(app)
.get_action_status(self.client_ids.conversation_id, &action.id);
is_streaming || status.is_some_and(|status| status.is_running()) is_streaming || status.is_some_and(|status| status.is_running())
}) })
}) })
@@ -5761,7 +5870,7 @@ impl AIBlock {
.any(|(action_id, requested_command)| { .any(|(action_id, requested_command)| {
self.action_model self.action_model
.as_ref(app) .as_ref(app)
.get_action_status(action_id) .get_action_status(self.client_ids.conversation_id, action_id)
.is_some_and(|status| status.is_running()) .is_some_and(|status| status.is_running())
&& requested_command.view.as_ref(app).is_header_expanded() && requested_command.view.as_ref(app).is_header_expanded()
}) })
@@ -5861,7 +5970,10 @@ impl AIBlock {
return String::new(); return String::new();
}; };
let output = output.get(); let output = output.get();
output.format_for_copy(Some(self.action_model.as_ref(app))) output.format_for_copy_for_conversation(
Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
)
} }
/// Gets AI output text for copying from the preceding user query until the next user query /// Gets AI output text for copying from the preceding user query until the next user query
@@ -5916,8 +6028,10 @@ impl AIBlock {
// Collect all AI outputs from start_idx to end_idx (exclusive) // Collect all AI outputs from start_idx to end_idx (exclusive)
let mut combined_result = Vec::new(); let mut combined_result = Vec::new();
for exchange in exchanges.iter().take(end_idx).skip(start_idx) { for exchange in exchanges.iter().take(end_idx).skip(start_idx) {
let formatted_output = let formatted_output = exchange.format_output_for_copy_for_conversation(
exchange.format_output_for_copy(Some(self.action_model.as_ref(app))); Some(self.action_model.as_ref(app)),
Some(self.client_ids.conversation_id),
);
if !formatted_output.is_empty() { if !formatted_output.is_empty() {
combined_result.push(formatted_output); combined_result.push(formatted_output);
} }
@@ -7089,7 +7203,7 @@ impl TypedActionView for AIBlock {
let Some(result) = self let Some(result) = self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(action_id) .get_action_result(self.client_ids.conversation_id, action_id)
.map(Arc::clone) .map(Arc::clone)
else { else {
continue; continue;
+4 -2
View File
@@ -1170,7 +1170,7 @@ impl View for CLISubagentView {
let is_cancelled = self let is_cancelled = self
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(&action.id) .get_action_status(self.conversation_id, &action.id)
.is_some_and(|status| status.is_cancelled()); .is_some_and(|status| status.is_cancelled());
if blocked_action.is_none() && !is_cancelled && !should_hide_responses { if blocked_action.is_none() && !is_cancelled && !should_hide_responses {
if let Some(rendered_action) = render_action(action.action.clone(), app) if let Some(rendered_action) = render_action(action.action.clone(), app)
@@ -1641,7 +1641,9 @@ fn should_retain_task_output_message(
|| (is_latest_exchange || (is_latest_exchange
&& matches!( && matches!(
message, message,
AIAgentOutputMessageType::Action(_) | AIAgentOutputMessageType::WebSearch(_) AIAgentOutputMessageType::Action(_)
| AIAgentOutputMessageType::RuntimeActivity(_)
| AIAgentOutputMessageType::WebSearch(_)
)) ))
} }
+376 -64
View File
@@ -15,6 +15,7 @@ use crate::ai::agent::{
}; };
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin}; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model; use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
use crate::ai::blocklist::{ use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
@@ -40,9 +41,14 @@ pub enum UserTakeOverReason {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
struct ActiveCLISubagentState { struct ActiveCLISubagentState {
initial_requested_command_conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>, initial_requested_command_action_id: Option<AIAgentActionId>,
task_id: Option<TaskId>, task_id: Option<TaskId>,
last_snapshot_at: Option<Instant>, last_snapshot_at: Option<Instant>,
/// Prevents a monitor turn that ended with prose and no tool call from recursively
/// generating nudges. A real snapshot/action result resets this so the next turn can be
/// nudged again if it stalls in the same way.
monitor_nudge_sent: bool,
completion: Option<PendingCommandCompletion>, completion: Option<PendingCommandCompletion>,
} }
@@ -52,6 +58,7 @@ struct PendingCommandCompletion {
initial_requested_command_action_id: Option<AIAgentActionId>, initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String, prompt: String,
completed_command: RunningCommand, completed_command: RunningCommand,
exit_code: i32,
final_turn_started: bool, final_turn_started: bool,
} }
@@ -67,7 +74,7 @@ impl UserTakeOverReason {
pub fn transfer_reason(&self) -> Option<&str> { pub fn transfer_reason(&self) -> Option<&str> {
match self { match self {
Self::TransferFromAgent { reason } => Some(reason.as_str()), Self::TransferFromAgent { reason } => Some(reason.as_str()),
_ => None, Self::Manual | Self::Stop => None,
} }
} }
} }
@@ -161,12 +168,25 @@ impl CLISubagentController {
return; return;
}; };
me.advance_completed_subagents(*conversation_id, ctx); me.advance_completed_subagents(*conversation_id, ctx);
me.ensure_monitor_continues(*conversation_id, ctx);
}); });
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event { ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => { BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock(); let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(true); active_block.update_is_agent_blocked(true);
let action_id = active_block.requested_command_action_id().cloned(); let action_id = active_block.requested_command_action_id().cloned();
@@ -176,9 +196,21 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(), agent_has_control: active_block.is_agent_in_control(),
}); });
} }
BlocklistAIActionEvent::ExecutingAction(..) => { BlocklistAIActionEvent::ExecutingAction {
action_id,
conversation_id,
..
} => {
let mut terminal_model = me.terminal_model.lock(); let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); let active_block = terminal_model.block_list_mut().active_block_mut();
if !matches_active_requested_command(
*conversation_id,
action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
return;
}
active_block.update_is_agent_blocked(false); active_block.update_is_agent_blocked(false);
let action_id = active_block.requested_command_action_id().cloned(); let action_id = active_block.requested_command_action_id().cloned();
@@ -190,12 +222,13 @@ impl CLISubagentController {
} }
BlocklistAIActionEvent::FinishedAction { BlocklistAIActionEvent::FinishedAction {
action_id: finished_action_id, action_id: finished_action_id,
conversation_id,
.. ..
} => { } => {
let action_result = me let action_result = me
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(finished_action_id); .get_action_result(*conversation_id, finished_action_id);
let initial_command_finished_without_snapshot = let initial_command_finished_without_snapshot =
action_result.is_some_and(|result| { action_result.is_some_and(|result| {
matches!( matches!(
@@ -215,38 +248,47 @@ impl CLISubagentController {
.cloned(); .cloned();
let mut terminal_model = me.terminal_model.lock(); let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut(); let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false); if matches_active_requested_command(
*conversation_id,
finished_action_id,
active_block.ai_conversation_id(),
active_block.requested_command_action_id(),
) {
active_block.update_is_agent_blocked(false);
let active_command_action_id = active_block.requested_command_action_id().cloned(); let active_command_action_id =
ctx.emit(CLISubagentEvent::UpdatedControl { active_block.requested_command_action_id().cloned();
block_id: active_block.id().clone(), ctx.emit(CLISubagentEvent::UpdatedControl {
requested_command_action_id: active_command_action_id, block_id: active_block.id().clone(),
agent_has_control: active_block.is_agent_in_control(), requested_command_action_id: active_command_action_id,
}); agent_has_control: active_block.is_agent_in_control(),
});
}
// Updates the last snapshot timestamp for the active block after the agent has read the block output. // Updates the last snapshot timestamp for the active block after the agent has read the block output.
if let Some(snapshot_block_id) = snapshot_block_id { if let Some(snapshot_block_id) = snapshot_block_id {
me.active_subagents_by_block let state = me
.active_subagents_by_block
.entry(snapshot_block_id.clone()) .entry(snapshot_block_id.clone())
.or_default() .or_default();
.last_snapshot_at = Some(Instant::now()); state.last_snapshot_at = Some(Instant::now());
state.monitor_nudge_sent = false;
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot); ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
} }
if initial_command_finished_without_snapshot { if initial_command_finished_without_snapshot {
me.active_subagents_by_block.retain(|_, state| { me.active_subagents_by_block.retain(|_, state| {
state.task_id.is_some() state.task_id.is_some()
|| state.initial_requested_command_action_id.as_ref() || !matches_requested_command_identity(
!= Some(finished_action_id) *conversation_id,
finished_action_id,
state.initial_requested_command_conversation_id,
state.initial_requested_command_action_id.as_ref(),
)
}); });
} }
drop(terminal_model);
if let Some(block_id) = command_finished_block_id { if let Some(block_id) = command_finished_block_id {
if let Some(completion) = me me.advance_completed_subagent(&block_id, ctx);
.active_subagents_by_block
.get_mut(&block_id)
.and_then(|state| state.completion.as_mut())
{
completion.final_turn_started = true;
}
} }
} }
_ => (), _ => (),
@@ -265,6 +307,8 @@ impl CLISubagentController {
let block_id = block.id().clone(); let block_id = block.id().clone();
let conversation_id = block.ai_conversation_id(); let conversation_id = block.ai_conversation_id();
let requested_command_action_id = block.requested_command_action_id().cloned(); let requested_command_action_id = block.requested_command_action_id().cloned();
let should_skip_completion_assessment =
!should_request_completion_assessment(block.long_running_control_state());
let completion = match (&block_completed_event.block_type, conversation_id) { let completion = match (&block_completed_event.block_type, conversation_id) {
(BlockType::User(completed), Some(conversation_id)) => { (BlockType::User(completed), Some(conversation_id)) => {
let command = if completed.command_with_obfuscated_secrets.is_empty() { let command = if completed.command_with_obfuscated_secrets.is_empty() {
@@ -294,6 +338,7 @@ impl CLISubagentController {
requested_command_id: requested_command_action_id.clone(), requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false, is_alt_screen_active: false,
}, },
exit_code,
final_turn_started: false, final_turn_started: false,
}) })
} }
@@ -310,17 +355,70 @@ impl CLISubagentController {
}; };
drop(terminal_model); drop(terminal_model);
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else { let provider_accepted_completion = completion.as_ref().is_some_and(|completion| {
return; let provider_completion = PendingProviderCommandCompletion::new(
}; completion.completed_command.block_id.clone(),
if subagent_state.last_snapshot_at.is_some() { completion.initial_requested_command_action_id.clone(),
completion.completed_command.command.clone(),
completion.completed_command.grid_contents.clone(),
completion.exit_code,
);
me.controller.update(ctx, |controller, ctx| {
controller.offer_provider_command_completion(
completion.conversation_id,
provider_completion,
ctx,
)
})
});
let has_last_snapshot = me
.active_subagents_by_block
.get(&block_id)
.is_some_and(|state| state.last_snapshot_at.is_some());
if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot); ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
} }
subagent_state.completion = completion; if provider_accepted_completion {
if subagent_state.completion.is_none() { // The provider controller owns deactivation after it applies the queued
// completion at a safe run boundary.
return;
}
if !me.active_subagents_by_block.contains_key(&block_id) {
return;
}
// A Stop takeover intentionally cancels the subagent. The command may still
// finish later, but that completion must not start a new assessment turn. Also
// clean up the in-memory monitor state so the stopped subagent cannot linger in
// the UI or intercept later refreshes.
if should_skip_completion_assessment {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
let has_completion = {
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id)
else {
return;
};
subagent_state.completion = completion;
subagent_state.completion.is_some()
};
if !has_completion {
log::warn!( log::warn!(
"CLI monitor block {block_id:?} completed without final command metadata" "CLI monitor block {block_id:?} completed without final command metadata"
); );
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return; return;
} }
me.advance_completed_subagent(&block_id, ctx); me.advance_completed_subagent(&block_id, ctx);
@@ -359,10 +457,10 @@ impl CLISubagentController {
} }
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) { fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some((task_id, completion)) = self let Some(completion) = self
.active_subagents_by_block .active_subagents_by_block
.get(block_id) .get(block_id)
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone()))) .and_then(|state| state.completion.as_ref().cloned())
else { else {
return; return;
}; };
@@ -380,14 +478,18 @@ impl CLISubagentController {
} }
if completion.final_turn_started { if completion.final_turn_started {
self.finish_completed_subagent(block_id, ctx); self.finish_subagent(
block_id,
Some(completion.conversation_id),
completion.initial_requested_command_action_id,
ctx,
);
return; return;
} }
let sent = self.controller.update(ctx, |controller, ctx| { let sent = self.controller.update(ctx, |controller, ctx| {
controller.send_command_completion_assessment( controller.send_command_completion_assessment(
completion.conversation_id, completion.conversation_id,
task_id,
completion.prompt, completion.prompt,
completion.completed_command, completion.completed_command,
ctx, ctx,
@@ -404,38 +506,125 @@ impl CLISubagentController {
} }
} }
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) { /// A monitor turn that returns only prose has no action result to trigger the normal
let Some(state) = self.active_subagents_by_block.remove(block_id) else { /// action-follow-up path. Nudge that monitor once with the live command context so a model
return; /// that acknowledged the first snapshot without polling gets another chance to inspect it.
}; fn ensure_monitor_continues(
let Some(completion) = state.completion else { &mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let Some(block_id) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| {
conversation.all_tasks().find_map(|task| {
let block_id = task.cli_subagent_block_id()?;
let state = self.active_subagents_by_block.get(&block_id)?;
if state.task_id.as_ref() != Some(task.id()) || state.completion.is_some() {
return None;
}
let last_exchange_has_action = task.last_exchange().is_some_and(|exchange| {
exchange
.output_status
.output()
.is_some_and(|output| output.get().actions().next().is_some())
});
should_nudge_monitor_turn(last_exchange_has_action, state.monitor_nudge_sent)
.then_some(block_id)
})
})
else {
return; return;
}; };
let deactivate_result = if self
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| { .controller
history_model.deactivate_cli_subagent_task_for_conversation( .as_ref(ctx)
block_id, .has_active_provider_run(conversation_id)
completion.conversation_id, || self
) .controller
}); .as_ref(ctx)
if let Err(error) = deactivate_result { .has_active_stream_for_conversation(conversation_id, ctx)
log::error!( || self
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}" .action_model
); .as_ref(ctx)
.has_unfinished_actions_for_conversation(conversation_id)
{
return;
}
let command_is_still_agent_controlled = {
let terminal_model = self.terminal_model.lock();
terminal_model
.block_list()
.block_with_id(&block_id)
.is_some_and(|block| {
block.is_active_and_long_running()
&& block.is_agent_in_control()
&& block.ai_conversation_id() == Some(conversation_id)
})
};
if !command_is_still_agent_controlled {
return;
}
if let Some(state) = self.active_subagents_by_block.get_mut(&block_id) {
state.monitor_nudge_sent = true;
}
self.controller.update(ctx, |controller, ctx| {
controller.send_cli_monitor_nudge(conversation_id, ctx);
});
}
fn finish_subagent(
&mut self,
block_id: &BlockId,
conversation_id: Option<AIConversationId>,
initial_requested_command_action_id: Option<AIAgentActionId>,
ctx: &mut ModelContext<Self>,
) {
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
return;
};
let conversation_id = conversation_id.or_else(|| {
state
.completion
.as_ref()
.map(|completion| completion.conversation_id)
});
let initial_requested_command_action_id = initial_requested_command_action_id
.or_else(|| {
state
.completion
.as_ref()
.and_then(|completion| completion.initial_requested_command_action_id.clone())
})
.or(state.initial_requested_command_action_id);
if let Some(conversation_id) = conversation_id {
let deactivate_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
});
if let Err(error) = deactivate_result {
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
}
} }
ctx.emit(CLISubagentEvent::FinishedSubagent { ctx.emit(CLISubagentEvent::FinishedSubagent {
block_id: block_id.clone(), block_id: block_id.clone(),
conversation_id: Some(completion.conversation_id), conversation_id,
initial_requested_command_action_id: completion.initial_requested_command_action_id, initial_requested_command_action_id,
}); });
if let Some(agent_view_controller) = &self.agent_view_controller { if let (Some(agent_view_controller), Some(conversation_id)) =
(&self.agent_view_controller, conversation_id)
{
agent_view_controller.update(ctx, |controller, ctx| { agent_view_controller.update(ctx, |controller, ctx| {
let is_this_inline_conversation = controller.is_inline() let is_this_inline_conversation = controller.is_inline()
&& controller.agent_view_state().active_conversation_id() && controller.agent_view_state().active_conversation_id()
== Some(completion.conversation_id); == Some(conversation_id);
if is_this_inline_conversation { if is_this_inline_conversation {
controller.exit_agent_view(ctx); controller.exit_agent_view(ctx);
} }
@@ -469,11 +658,18 @@ impl CLISubagentController {
/// ///
/// The placeholder lets command completion and action-result events arrive in either order /// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs. /// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(&mut self, block_id: &BlockId, action_id: &AIAgentActionId) { pub fn track_requested_command(
self.active_subagents_by_block &mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) {
let state = self
.active_subagents_by_block
.entry(block_id.clone()) .entry(block_id.clone())
.or_default() .or_default();
.initial_requested_command_action_id = Some(action_id.clone()); state.initial_requested_command_conversation_id = Some(conversation_id);
state.initial_requested_command_action_id = Some(action_id.clone());
} }
/// Force the currently in-flight poll for the given long-running command block to /// Force the currently in-flight poll for the given long-running command block to
@@ -609,13 +805,7 @@ impl CLISubagentController {
.collect() .collect()
}; };
self.controller.update(ctx, |controller, ctx| { self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation( controller.resume_conversation(conversation_id, resume_context, ctx);
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
}); });
} }
} }
@@ -726,6 +916,10 @@ impl CLISubagentController {
requested_command_action_id: action_id.clone(), requested_command_action_id: action_id.clone(),
agent_has_control, agent_has_control,
}); });
self.active_subagents_by_block
.entry(block_id.clone())
.or_default()
.initial_requested_command_conversation_id = Some(conversation_id);
self.active_subagents_by_block self.active_subagents_by_block
.entry(block_id.clone()) .entry(block_id.clone())
.or_default() .or_default()
@@ -874,6 +1068,7 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
AIAgentActionResultType::RequestCommandOutput( AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::LongRunningCommandSnapshot { .. } RequestCommandOutputResult::LongRunningCommandSnapshot { .. }
| RequestCommandOutputResult::CancelledBeforeExecution | RequestCommandOutputResult::CancelledBeforeExecution
| RequestCommandOutputResult::ExecutionError { .. }
| RequestCommandOutputResult::Denylisted { .. }, | RequestCommandOutputResult::Denylisted { .. },
) )
| AIAgentActionResultType::WriteToLongRunningShellCommand( | AIAgentActionResultType::WriteToLongRunningShellCommand(
@@ -919,3 +1114,120 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
| AIAgentActionResultType::WaitForEvents(_) => None, | AIAgentActionResultType::WaitForEvents(_) => None,
} }
} }
fn should_request_completion_assessment(
control_state: Option<&LongRunningCommandControlState>,
) -> bool {
!control_state
.and_then(LongRunningCommandControlState::user_take_over_reason)
.is_some_and(UserTakeOverReason::is_stop)
}
fn should_nudge_monitor_turn(last_exchange_has_action: bool, monitor_nudge_sent: bool) -> bool {
!last_exchange_has_action && !monitor_nudge_sent
}
fn matches_active_requested_command(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
active_conversation_id: Option<AIConversationId>,
active_requested_command_id: Option<&AIAgentActionId>,
) -> bool {
active_conversation_id == Some(event_conversation_id)
&& active_requested_command_id == Some(event_action_id)
}
fn matches_requested_command_identity(
event_conversation_id: AIConversationId,
event_action_id: &AIAgentActionId,
requested_command_conversation_id: Option<AIConversationId>,
requested_command_action_id: Option<&AIAgentActionId>,
) -> bool {
requested_command_conversation_id == Some(event_conversation_id)
&& requested_command_action_id == Some(event_action_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_takeover_does_not_request_a_completion_assessment() {
let state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::Stop,
};
assert!(!should_request_completion_assessment(Some(&state)));
}
#[test]
fn non_stop_control_states_can_request_a_completion_assessment() {
let agent_state = LongRunningCommandControlState::Agent {
is_blocked: false,
should_hide_responses: false,
};
let transfer_state = LongRunningCommandControlState::User {
reason: UserTakeOverReason::TransferFromAgent {
reason: "needs user input".to_owned(),
},
};
assert!(should_request_completion_assessment(None));
assert!(should_request_completion_assessment(Some(&agent_state)));
assert!(should_request_completion_assessment(Some(&transfer_state)));
}
#[test]
fn prose_monitor_turn_is_nudged_once_until_a_tool_action_runs() {
assert!(should_nudge_monitor_turn(false, false));
assert!(!should_nudge_monitor_turn(false, true));
assert!(!should_nudge_monitor_turn(true, false));
}
#[test]
fn shell_control_event_must_match_conversation_and_requested_command() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let active_action_id = AIAgentActionId::from("same-action".to_owned());
let other_action_id = AIAgentActionId::from("other-action".to_owned());
assert!(matches_active_requested_command(
active_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
other_conversation_id,
&active_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
assert!(!matches_active_requested_command(
active_conversation_id,
&other_action_id,
Some(active_conversation_id),
Some(&active_action_id),
));
}
#[test]
fn requested_command_identity_rejects_duplicate_id_from_another_conversation() {
let active_conversation_id = AIConversationId::new();
let other_conversation_id = AIConversationId::new();
let duplicate_action_id = AIAgentActionId::from("duplicate-action".to_owned());
assert!(matches_requested_command_identity(
active_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
assert!(!matches_requested_command_identity(
other_conversation_id,
&duplicate_action_id,
Some(active_conversation_id),
Some(&duplicate_action_id),
));
}
}
+10
View File
@@ -1,5 +1,6 @@
use std::time::Duration; use std::time::Duration;
use galaxy_agent_core::RuntimeActivity;
use galaxy_terminal::model::escape_sequences; use galaxy_terminal::model::escape_sequences;
use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message}; use super::{get_action_icon, get_action_loading_text, should_retain_task_output_message};
@@ -58,4 +59,13 @@ fn transcript_retains_prior_text_but_only_latest_tool_activity() {
}); });
assert!(!should_retain_task_output_message(&poll, false)); assert!(!should_retain_task_output_message(&poll, false));
assert!(should_retain_task_output_message(&poll, true)); assert!(should_retain_task_output_message(&poll, true));
let runtime_activity = AIAgentOutputMessageType::RuntimeActivity(RuntimeActivity {
id: "acp-tool".to_owned(),
title: "Inspect repository".to_owned(),
status: None,
output: None,
});
assert!(!should_retain_task_output_message(&runtime_activity, false));
assert!(should_retain_task_output_message(&runtime_activity, true));
} }
+5 -1
View File
@@ -149,7 +149,11 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
let output = output.get(); let output = output.get();
output.messages.iter().find_map(|message| { output.messages.iter().find_map(|message| {
if let AIAgentOutputMessageType::Action(action) = &message.message { if let AIAgentOutputMessageType::Action(action) = &message.message {
if let Some(status) = action_model.as_ref(app).get_action_status(&action.id) { if let Some(status) = self.conversation_id(app).and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, &action.id)
}) {
return status.is_blocked().then_some(action.clone()); return status.is_blocked().then_some(action.clone());
} }
} }
+21 -4
View File
@@ -328,10 +328,27 @@ impl BlocklistAIStatusBar {
}, },
); );
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event { ctx.subscribe_to_model(&action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction(..) BlocklistAIActionEvent::ExecutingAction {
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(), conversation_id, ..
_ => (), }
| BlocklistAIActionEvent::FinishedAction {
conversation_id, ..
} if me
.active_exchange_model
.as_ref()
.is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id)) =>
{
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction { .. }
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. }
| BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. }
| BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_)
| BlocklistAIActionEvent::InsertCodeReviewComments { .. } => {}
}); });
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event { ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| match event {
ModelEvent::AfterBlockStarted { block_id, .. } => { ModelEvent::AfterBlockStarted { block_id, .. } => {
+2
View File
@@ -1079,6 +1079,7 @@ impl View for AIBlock {
contents.add_child(output::render( contents.add_child(output::render(
output::Props { output::Props {
conversation_id: self.client_ids.conversation_id,
model: self.model.as_ref(), model: self.model.as_ref(),
state_handles: &self.state_handles, state_handles: &self.state_handles,
action_buttons: &self.action_buttons, action_buttons: &self.action_buttons,
@@ -1375,6 +1376,7 @@ impl AIAgentInput {
app, app,
)), )),
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. } | AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. } | AIAgentInput::InitProjectRules { .. }
@@ -3687,6 +3687,7 @@ pub(super) fn query_prefix_highlight_len(
match input { match input {
AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()), AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()),
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. } | AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. } | AIAgentInput::InitProjectRules { .. }
@@ -420,7 +420,10 @@ pub(super) fn render_send_message(
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let orchestrator_agent_id = props let orchestrator_agent_id = props
.model .model
.conversation(app) .conversation(app)
@@ -564,7 +567,10 @@ pub(super) fn render_start_agent(
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
let status = props.action_model.as_ref(app).get_action_status(action_id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
if let Some(AIActionStatus::Finished(result)) = &status { if let Some(AIActionStatus::Finished(result)) = &status {
let AIAgentActionResultType::StartAgent(result) = &result.result else { let AIAgentActionResultType::StartAgent(result) = &result.result else {
+189 -28
View File
@@ -16,12 +16,13 @@ use ai::agent::action::{
}; };
use ai::agent::file_locations::group_file_contexts_for_display; use ai::agent::file_locations::group_file_contexts_for_display;
use ai::skills::{ParsedSkill, SkillReference}; use ai::skills::{ParsedSkill, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use galaxy_core::channel::ChannelState; use galaxy_core::channel::ChannelState;
use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::color::internal_colors;
use galaxy_util::local_or_remote_path::LocalOrRemotePath; use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::elements::new_scrollable::SingleAxisConfig; use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{ use galaxyui::elements::{
Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable, CrossAxisAlignment, Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable,
MainAxisAlignment, MainAxisSize, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement, MainAxisAlignment, MainAxisSize, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Shrinkable, Stack, Text, Wrap, ParentOffsetBounds, Radius, Shrinkable, Stack, Text, Wrap,
@@ -55,6 +56,7 @@ use super::{
}; };
use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::comment::ReviewComment; use crate::ai::agent::comment::ReviewComment;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon}; use crate::ai::agent::icons::{self, gray_stop_icon, yellow_stop_icon};
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
@@ -84,18 +86,22 @@ use crate::ai::blocklist::inline_action::ask_user_question_view::AskUserQuestion
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView; use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction; use crate::ai::blocklist::inline_action::create_or_edit_document::CreateOrEditDocumentAction;
use crate::ai::blocklist::inline_action::inline_action_header::{ use crate::ai::blocklist::inline_action::inline_action_header::{
HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING, ExpandedConfig, HeaderConfig, InteractionMode, INLINE_ACTION_HEADER_VERTICAL_PADDING,
INLINE_ACTION_HORIZONTAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
}; };
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size}; use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
use crate::ai::blocklist::inline_action::requested_action::{ use crate::ai::blocklist::inline_action::requested_action::{
render_requested_action_body_text, render_requested_action_row_for_text, RenderableAction, render_requested_action_body_text, render_requested_action_row_for_text, RenderableAction,
}; };
use crate::ai::blocklist::inline_action::requested_command::RequestedCommand; use crate::ai::blocklist::inline_action::requested_command::{
format_command_text, RequestedCommand, REQUESTED_COMMAND_BODY_VERTICAL_PADDING,
VIEWING_COMMAND_DETAIL_MESSAGE,
};
use crate::ai::blocklist::inline_action::run_agents_card_view::RunAgentsCardView; use crate::ai::blocklist::inline_action::run_agents_card_view::RunAgentsCardView;
use crate::ai::blocklist::inline_action::search_codebase::SearchCodebaseView; use crate::ai::blocklist::inline_action::search_codebase::SearchCodebaseView;
use crate::ai::blocklist::inline_action::suggested_unit_tests::SuggestedUnitTestsView; use crate::ai::blocklist::inline_action::suggested_unit_tests::SuggestedUnitTestsView;
use crate::ai::blocklist::inline_action::summarization::SummarizationView; use crate::ai::blocklist::inline_action::summarization::SummarizationView;
use crate::ai::blocklist::inline_action::tool_pane::render_tool_pane_shell;
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView; use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
use crate::ai::blocklist::inline_action::web_search::WebSearchView; use crate::ai::blocklist::inline_action::web_search::WebSearchView;
use crate::ai::blocklist::keyboard_navigable_buttons::KeyboardNavigableButtons; use crate::ai::blocklist::keyboard_navigable_buttons::KeyboardNavigableButtons;
@@ -131,9 +137,14 @@ use crate::{AIAgentTodoList, FeatureFlag};
const BLOCKED_ACTION_MESSAGE_FOR_UPLOADING_ARTIFACT: &str = "Grant access to upload this artifact?"; const BLOCKED_ACTION_MESSAGE_FOR_UPLOADING_ARTIFACT: &str = "Grant access to upload this artifact?";
fn should_render_requested_edit(action_status: Option<&AIActionStatus>) -> bool {
!action_status.is_some_and(AIActionStatus::is_preprocessing)
}
/// Data required to render the AI block output component. /// Data required to render the AI block output component.
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub(crate) struct Props<'a> { pub(crate) struct Props<'a> {
pub(crate) conversation_id: AIConversationId,
pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>, pub(crate) model: &'a dyn AIBlockModel<View = AIBlock>,
pub(super) state_handles: &'a AIBlockStateHandles, pub(super) state_handles: &'a AIBlockStateHandles,
pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>, pub(super) action_buttons: &'a HashMap<AIAgentActionId, ActionButtons>,
@@ -400,6 +411,21 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
} if !are_all_text_sections_empty(sections) => { } if !are_all_text_sections_empty(sections) => {
text_section_index += sections.len(); text_section_index += sections.len();
} }
AIAgentOutputMessageType::RuntimeActivity(activity) => {
if !matches!(
activity.status,
Some(RuntimeActivityStatus::Completed)
| Some(RuntimeActivityStatus::Failed)
) {
should_render_footer = false;
should_render_suggestions = false;
}
if let Some(rendered_activity) =
render_runtime_activity(output_message, activity, props, app)
{
output_items.add_child(rendered_activity);
}
}
AIAgentOutputMessageType::Action(AIAgentAction { AIAgentOutputMessageType::Action(AIAgentAction {
action: AIAgentActionType::RequestCommandOutput { .. }, action: AIAgentActionType::RequestCommandOutput { .. },
id, id,
@@ -412,7 +438,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props let is_action_done = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(id) .get_action_status(props.conversation_id, id)
.as_ref() .as_ref()
.is_some_and(|status| status.is_done()); .is_some_and(|status| status.is_done());
if !is_action_done { if !is_action_done {
@@ -452,7 +478,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let agent_action_results = props let agent_action_results = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(id) .get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref()); .map(|action_result| action_result.as_ref());
// checks if the read file action result is completed and successful. // checks if the read file action result is completed and successful.
@@ -541,13 +567,12 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
id, id,
.. ..
}) => { }) => {
let action_status = let action_status = props
props.action_model.as_ref(app).get_action_status(id); .action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_preprocessing = action_status if should_render_requested_edit(action_status.as_ref()) {
.clone()
.is_some_and(|status| status.is_preprocessing());
if !is_preprocessing && !status.is_streaming() {
if let Some(requested_edit) = props.requested_edits.get(id) { if let Some(requested_edit) = props.requested_edits.get(id) {
// Don't render the requested edit if the diffs are empty for passive code diffs. // Don't render the requested edit if the diffs are empty for passive code diffs.
if request_type.is_passive_code_diff() if request_type.is_passive_code_diff()
@@ -635,7 +660,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let is_action_done = props let is_action_done = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(id) .get_action_status(props.conversation_id, id)
.as_ref() .as_ref()
.is_some_and(|status| status.is_done()); .is_some_and(|status| status.is_done());
if !is_action_done { if !is_action_done {
@@ -1262,6 +1287,106 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
output_items.finish() output_items.finish()
} }
fn render_runtime_activity(
output_message: &AIAgentOutputMessage,
activity: &RuntimeActivity,
props: Props,
app: &AppContext,
) -> Option<Box<dyn Element>> {
let state = props.collapsible_block_states.get(&output_message.id)?;
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let output = activity
.output
.as_deref()
.filter(|output| !output.is_empty());
let is_expanded = matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded { .. }
);
let icon = match activity.status.as_ref() {
Some(RuntimeActivityStatus::Pending) => icons::pending_icon(appearance),
Some(RuntimeActivityStatus::InProgress) => icons::yellow_running_icon(appearance),
Some(RuntimeActivityStatus::Completed) => inline_action_icons::green_check_icon(appearance),
Some(RuntimeActivityStatus::Failed) => inline_action_icons::red_x_icon(appearance),
Some(RuntimeActivityStatus::Other(_)) | None => icons::gray_circle_icon(appearance),
};
let title = if is_expanded {
VIEWING_COMMAND_DETAIL_MESSAGE.to_owned()
} else {
format_command_text(&activity.title)
};
let mut header = HeaderConfig::new(title, app)
.with_selectable_text()
.with_icon(icon)
.with_corner_radius_override(if is_expanded && output.is_some() {
CornerRadius::with_top(Radius::Pixels(8.))
} else {
CornerRadius::with_all(Radius::Pixels(8.))
});
if !is_expanded {
header = header.with_font_family(appearance.monospace_font_family());
}
if output.is_some() {
let message_id = output_message.id.clone();
let command = activity.title.clone();
let expansion =
ExpandedConfig::new(is_expanded, state.expansion_toggle_mouse_state.clone())
.with_toggle_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
message_id.clone(),
));
})
.with_right_click_callback(move |ctx| {
ctx.dispatch_typed_action(AIBlockAction::StoreRightClickedCommand {
command: command.clone(),
});
});
header = header.with_interaction_mode(InteractionMode::ManuallyExpandable(expansion));
}
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Clipped::new(header.render(app)).finish());
if let Some(output) = output {
let body = render_requested_action_body_text(
output.into(),
appearance.monospace_font_family(),
app,
)
.finish();
let is_streaming = matches!(
activity.status,
Some(RuntimeActivityStatus::Pending | RuntimeActivityStatus::InProgress)
);
if let Some(scrollable) = render_scrollable_collapsible_content(
&output_message.id,
state,
body,
is_streaming,
320.,
) {
content.add_child(
Container::new(scrollable)
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(REQUESTED_COMMAND_BODY_VERTICAL_PADDING)
.with_background(theme.background())
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
.finish(),
);
}
}
Some(render_tool_pane_shell(
content.finish(),
false,
is_expanded,
false,
app,
))
}
fn should_render_stopped_output(props: Props, app: &AppContext) -> bool { fn should_render_stopped_output(props: Props, app: &AppContext) -> bool {
if FeatureFlag::AgentView.is_enabled() { if FeatureFlag::AgentView.is_enabled() {
return false; return false;
@@ -1358,7 +1483,10 @@ fn render_search_codebase(
id: &AIAgentActionId, id: &AIAgentActionId,
app: &AppContext, app: &AppContext,
) -> Option<Box<dyn Element>> { ) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
@@ -1859,7 +1987,10 @@ fn render_read_files(
parsed_skill: Option<&ai::skills::ParsedSkill>, parsed_skill: Option<&ai::skills::ParsedSkill>,
action_index: usize, action_index: usize,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let status = props.action_model.as_ref(app).get_action_status(id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let formatted_files = let formatted_files =
render_read_files_text(props.into(), file_names, app, appearance, action_index); render_read_files_text(props.into(), file_names, app, appearance, action_index);
@@ -1976,7 +2107,10 @@ fn maybe_render_edit_document(
id: &AIAgentActionId, id: &AIAgentActionId,
app: &AppContext, app: &AppContext,
) -> Option<Box<dyn Element>> { ) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now // Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) { if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -1986,7 +2120,7 @@ fn maybe_render_edit_document(
let agent_action_results = props let agent_action_results = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(id) .get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref()); .map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult { let Some(AIAgentActionResult {
@@ -2013,7 +2147,10 @@ fn maybe_render_create_document(
id: &AIAgentActionId, id: &AIAgentActionId,
app: &AppContext, app: &AppContext,
) -> Option<Box<dyn Element>> { ) -> Option<Box<dyn Element>> {
let status = props.action_model.as_ref(app).get_action_status(id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
// Document operations are always auto-executed for now // Document operations are always auto-executed for now
if status.as_ref().is_some_and(|status| status.is_blocked()) { if status.as_ref().is_some_and(|status| status.is_blocked()) {
@@ -2023,7 +2160,7 @@ fn maybe_render_create_document(
let agent_action_results = props let agent_action_results = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(id) .get_action_result(props.conversation_id, id)
.map(|action_result| action_result.as_ref()); .map(|action_result| action_result.as_ref());
let Some(AIAgentActionResult { let Some(AIAgentActionResult {
@@ -2326,7 +2463,7 @@ fn render_suggest_new_conversation(
let status = props let status = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_status(action_id) .get_action_status(props.conversation_id, action_id)
.unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult { .unwrap_or(AIActionStatus::Finished(Arc::new(AIAgentActionResult {
result: AIAgentActionResultType::SuggestNewConversation( result: AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled, SuggestNewConversationResult::Cancelled,
@@ -2434,7 +2571,10 @@ fn create_formatted_text_for_grep(
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id); let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status let is_cancelled = action_status
.as_ref() .as_ref()
.is_some_and(|status| status.is_cancelled()); .is_some_and(|status| status.is_cancelled());
@@ -2538,7 +2678,10 @@ fn create_formatted_text_for_file_glob(
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let theme = appearance.theme(); let theme = appearance.theme();
let action_status = props.action_model.as_ref(app).get_action_status(id); let action_status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, id);
let is_cancelled = action_status let is_cancelled = action_status
.as_ref() .as_ref()
.is_some_and(|status| status.is_cancelled()); .is_some_and(|status| status.is_cancelled());
@@ -2639,7 +2782,10 @@ fn render_file_retrieval_tool(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app); let mut config = RenderableAction::new_with_formatted_text(tool_formatted_text, app);
@@ -2756,7 +2902,10 @@ fn render_read_mcp_resource(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(name, app); let mut renderable_action = RenderableAction::new(name, app);
@@ -2833,11 +2982,14 @@ fn render_upload_artifact(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let result = props let result = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(action_id) .get_action_result(props.conversation_id, action_id)
.and_then(|result| match &result.result { .and_then(|result| match &result.result {
AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result), AIAgentActionResultType::UploadArtifact(upload_result) => Some(upload_result),
_ => None, _ => None,
@@ -2896,7 +3048,7 @@ fn render_use_computer(
let has_screenshot = props let has_screenshot = props
.action_model .action_model
.as_ref(app) .as_ref(app)
.get_action_result(action_id) .get_action_result(props.conversation_id, action_id)
.is_some_and(|result| { .is_some_and(|result| {
matches!( matches!(
&result.result, &result.result,
@@ -2942,7 +3094,10 @@ fn render_request_computer_use(
app: &AppContext, app: &AppContext,
) -> Box<dyn Element> { ) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let status = props.action_model.as_ref(app).get_action_status(action_id); let status = props
.action_model
.as_ref(app)
.get_action_status(props.conversation_id, action_id);
let mut renderable_action = RenderableAction::new(&request.task_summary, app); let mut renderable_action = RenderableAction::new(&request.task_summary, app);
@@ -3523,7 +3678,13 @@ pub fn action_icon<V: View>(
app: &AppContext, app: &AppContext,
) -> galaxyui::elements::Icon { ) -> galaxyui::elements::Icon {
let appearance = Appearance::as_ref(app); let appearance = Appearance::as_ref(app);
let status = action_model.as_ref(app).get_action_status(action_id); let status = ai_block_model
.conversation_id(app)
.and_then(|conversation_id| {
action_model
.as_ref(app)
.get_action_status(conversation_id, action_id)
});
match status { match status {
Some(status) => match status { Some(status) => match status {
AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance), AIActionStatus::Preprocessing => icons::gray_circle_icon(appearance),
@@ -11,12 +11,23 @@ use watcher::HomeDirectoryWatcher;
use super::{ use super::{
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text, format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
should_render_requested_edit,
}; };
use crate::ai::agent::UploadArtifactResult; use crate::ai::agent::UploadArtifactResult;
use crate::ai::blocklist::action_model::AIActionStatus;
use crate::ai::skills::SkillManager; use crate::ai::skills::SkillManager;
use crate::settings::AISettings; use crate::settings::AISettings;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
#[test]
fn requested_edits_render_as_soon_as_preprocessing_finishes() {
assert!(!should_render_requested_edit(Some(
&AIActionStatus::Preprocessing
)));
assert!(should_render_requested_edit(Some(&AIActionStatus::Blocked)));
assert!(should_render_requested_edit(None));
}
#[test] #[test]
fn format_upload_artifact_text_includes_request_details() { fn format_upload_artifact_text_includes_request_details() {
let request = UploadArtifactRequest { let request = UploadArtifactRequest {
+48
View File
@@ -103,6 +103,54 @@ fn collapsed_initializer_starts_collapsed() {
)); ));
} }
#[test]
fn completed_runtime_activity_stays_collapsed_until_opened() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test]
fn streaming_runtime_activity_expands_when_output_arrives() {
let mut state = CollapsibleElementState::collapsed();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: false,
scroll_pinned_to_bottom: true
}
));
state.sync_runtime_activity(false, true, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Expanded {
is_finished: true,
scroll_pinned_to_bottom: false
}
));
}
#[test]
fn manually_collapsed_streaming_runtime_activity_stays_collapsed() {
let mut state = CollapsibleElementState::default();
state.toggle_expansion();
state.sync_runtime_activity(true, false, true);
assert!(matches!(
state.expansion_state,
CollapsibleExpansionState::Collapsed
));
}
#[test] #[test]
fn orchestration_show_and_collapse_collapses_after_finish() { fn orchestration_show_and_collapse_collapses_after_finish() {
let mut state = default_collapsible_state_for_orchestration_message( let mut state = default_collapsible_state_for_orchestration_message(
+25 -1
View File
@@ -188,13 +188,37 @@ impl BlocklistAIContextModel {
); );
ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| {
if let LLMPreferencesEvent::UpdatedActiveAgentModeLLM = event { if matches!(
event,
LLMPreferencesEvent::UpdatedActiveAgentModeLLM
| LLMPreferencesEvent::UpdatedAvailableLLMs
) {
let llm_prefs = LLMPreferences::as_ref(ctx); let llm_prefs = LLMPreferences::as_ref(ctx);
let vision_supported = let vision_supported =
llm_prefs.vision_supported(ctx, Some(me.terminal_surface_id)); llm_prefs.vision_supported(ctx, Some(me.terminal_surface_id));
#[cfg(not(target_family = "wasm"))]
let desired_backend =
llm_prefs.agent_backend_for_active_model(Some(me.terminal_surface_id), ctx);
if !vision_supported { if !vision_supported {
me.clear_pending_images(ctx); me.clear_pending_images(ctx);
} }
// ACP and provider histories have different owners. When the
// selected model crosses that boundary, make the next prompt a
// fresh conversation instead of silently sending it through
// the backend that owned the existing conversation.
#[cfg(not(target_family = "wasm"))]
{
let selected_backend = me
.selected_conversation(ctx)
.map(|conversation| conversation.agent_backend().clone());
if selected_backend.is_some_and(|backend| backend != desired_backend) {
me.set_pending_query_state_for_new_conversation(
AgentViewEntryOrigin::ConversationSelector,
ctx,
);
}
}
} }
}); });
File diff suppressed because it is too large Load Diff
@@ -52,11 +52,15 @@ impl PendingResponseStreams {
.collect() .collect()
} }
/// Attempts to inject a plain-text follow-up into the active ACP turn. pub fn has_stream(&self, stream_id: &ResponseStreamId) -> bool {
self.streams.contains_key(stream_id)
}
/// Attempts to inject a plain-text follow-up into an active steerable runtime.
/// ///
/// Returning `None` leaves the caller free to use the normal /// Returning `None` leaves the caller free to use the normal
/// cancel-and-queue path without dropping the user's message. /// cancel-and-queue path without dropping the user's message.
pub fn try_steer_acp_stream_for_conversation( pub fn try_steer_runtime_for_conversation(
&self, &self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
display_text: String, display_text: String,
@@ -71,7 +75,7 @@ impl PendingResponseStreams {
let model_id = stream.as_ref(app).llm_id().clone(); let model_id = stream.as_ref(app).llm_id().clone();
stream stream
.as_ref(app) .as_ref(app)
.try_steer_acp(display_text) .try_steer_runtime(display_text)
.then(|| (stream_id.clone(), model_id)) .then(|| (stream_id.clone(), model_id))
} }
@@ -87,6 +91,14 @@ impl PendingResponseStreams {
self.streams.insert(stream_id, stream); self.streams.insert(stream_id, stream);
} }
pub fn register_additional_stream(
&mut self,
stream_id: ResponseStreamId,
stream: ModelHandle<ResponseStream>,
) {
self.streams.insert(stream_id, stream);
}
pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) { pub fn cleanup_stream(&mut self, stream_id: &ResponseStreamId) {
self.streams.remove(stream_id); self.streams.remove(stream_id);
} }
@@ -136,11 +148,13 @@ impl PendingResponseStreams {
false false
} else { } else {
for response_stream in streams_to_cancel.into_iter() { for response_stream in streams_to_cancel.into_iter() {
log::info!( crate::ai::tool_diagnostics::tool_debug!(
"Canceling active stream for conversation_id={conversation_id:?}, \ "Canceling active stream for conversation_id={conversation_id:?}, \
reason={reason}, backtrace=\n{}", reason={reason}"
std::backtrace::Backtrace::force_capture()
); );
if let Some(backtrace) = crate::ai::tool_diagnostics::capture_backtrace() {
log::debug!("Active stream cancellation backtrace:\n{backtrace}");
}
response_stream.update(ctx, |stream, ctx| { response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx) stream.cancel(reason, conversation_id, ctx)
}); });
File diff suppressed because it is too large Load Diff
@@ -1,86 +1,22 @@
use super::{is_interactive_remote_command, recovery_action, RecoveryAction}; use warp_multi_agent_api::response_event::stream_finished;
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget, use super::{is_interactive_remote_command, stream_finished_llm_finished};
// can_attempt_resume_on_error, is_online.
#[test] #[test]
fn pre_action_failures_retry() { fn response_finish_reason_reports_whether_the_llm_completed() {
assert_eq!( assert!(stream_finished_llm_finished(&None));
recovery_action(false, true, true, true, true), assert!(stream_finished_llm_finished(&Some(
RecoveryAction::RetryNow stream_finished::Reason::Done(stream_finished::Done {})
); )));
// Resume eligibility is irrelevant pre-actions. assert!(stream_finished_llm_finished(&Some(
assert_eq!( stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {})
recovery_action(false, true, true, false, true), )));
RecoveryAction::RetryNow assert!(!stream_finished_llm_finished(&Some(
); stream_finished::Reason::Other(stream_finished::Other {})
} )));
assert!(!stream_finished_llm_finished(&Some(
#[test] stream_finished::Reason::LlmUnavailable(stream_finished::LlmUnavailable {})
fn pre_action_failures_wait_for_connectivity_when_offline() { )));
assert_eq!(
recovery_action(false, true, true, true, false),
RecoveryAction::RetryWhenOnline
);
}
#[test]
fn pre_action_budget_exhaustion_is_terminal() {
// The request has already been retried MAX_RETRIES times; stop.
assert_eq!(
recovery_action(false, true, false, true, true),
RecoveryAction::Fail
);
assert_eq!(
recovery_action(false, true, false, true, false),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_pre_action_failure_is_terminal() {
assert_eq!(
recovery_action(false, false, true, true, true),
RecoveryAction::Fail
);
}
#[test]
fn post_action_recoverable_failures_resume() {
assert_eq!(
recovery_action(true, true, true, true, true),
RecoveryAction::Resume
);
// Offline doesn't change the decision; the resume spawn waits for connectivity.
assert_eq!(
recovery_action(true, true, true, true, false),
RecoveryAction::Resume
);
// The in-request retry budget is irrelevant once actions have executed.
assert_eq!(
recovery_action(true, true, false, true, true),
RecoveryAction::Resume
);
}
#[test]
fn post_action_failures_without_resume_eligibility_are_terminal() {
// Resume requests themselves run with can_attempt_resume_on_error=false,
// bounding recovery to a single resume.
assert_eq!(
recovery_action(true, true, true, false, true),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_post_action_failure_is_terminal() {
// A non-recoverable error (e.g. a client error) ends the conversation even
// after actions have executed.
assert_eq!(
recovery_action(true, false, true, true, true),
RecoveryAction::Fail
);
} }
#[test] #[test]
@@ -354,7 +354,7 @@ impl BlocklistAIController {
if self if self
.action_model .action_model
.as_ref(ctx) .as_ref(ctx)
.get_action_result(&result.id) .get_action_result(conversation_id, &result.id)
.is_none() .is_none()
{ {
self.action_model.update(ctx, |action_model, ctx| { self.action_model.update(ctx, |action_model, ctx| {
@@ -148,6 +148,19 @@ impl SlashCommandRequest {
is_for_same_conversation: active_conversation_id is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == conversation_id), .is_some_and(|id| id == conversation_id),
}; };
if controller.should_block_submission_for_unresolved_ask_user_question(
Some(conversation_id),
active_conversation_id,
ctx,
) {
controller.log_blocked_submission_for_unresolved_ask_user_question(
Some(conversation_id),
active_conversation_id,
is_queued_prompt,
ctx,
);
return;
}
if let Some(active_conversation_id) = active_conversation_id { if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress( controller.cancel_conversation_progress(
active_conversation_id, active_conversation_id,
@@ -181,7 +194,6 @@ impl SlashCommandRequest {
entrypoint, entrypoint,
is_auto_resume_after_error: false, is_auto_resume_after_error: false,
}), }),
/*can_attempt_resume_on_error*/ true,
is_queued_prompt, is_queued_prompt,
ctx, ctx,
) { ) {
File diff suppressed because it is too large Load Diff
+204 -52
View File
@@ -33,14 +33,16 @@ use crate::ai::agent::conversation::{
use crate::ai::agent::task::helper::{MessageExt, ToolCallExt}; use crate::ai::agent::task::helper::{MessageExt, ToolCallExt};
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, AIAgentAction, AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost, AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError,
Suggestions, RequestCost, Suggestions,
}; };
use crate::ai::artifacts::Artifact; use crate::ai::artifacts::Artifact;
use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::document::ai_document_model::AIDocumentModel;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::llms::LLMPreferences; use crate::ai::llms::LLMPreferences;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
use crate::input_suggestions::HistoryOrder; use crate::input_suggestions::HistoryOrder;
use crate::persistence::model::{ use crate::persistence::model::{
AcpConversationData, AgentBackend, AgentConversation, AgentConversationData, AcpConversationData, AgentBackend, AgentConversation, AgentConversationData,
@@ -561,6 +563,44 @@ impl BlocklistAIHistoryModel {
conversation.write_updated_conversation_state(ctx); conversation.write_updated_conversation_state(ctx);
} }
pub(crate) fn persist_active_provider_run_json(
&mut self,
conversation_id: AIConversationId,
snapshot: Option<String>,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.set_active_provider_run_json(snapshot);
conversation.write_updated_conversation_state(ctx);
Ok(())
}
pub(crate) fn rebind_provider_projection(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.rebind_provider_projection(
task_id,
exchange_id,
response_stream_id,
terminal_surface_id,
ctx,
)?;
Ok(())
}
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) { fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else { let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
return; return;
@@ -1182,6 +1222,87 @@ impl BlocklistAIHistoryModel {
}); });
} }
fn configured_agent_backend(
terminal_surface_id: EntityId,
is_viewing_shared_session: bool,
is_cli_agent_transcript: bool,
ctx: &AppContext,
) -> AgentBackend {
if is_viewing_shared_session
|| is_cli_agent_transcript
|| !cfg!(unix)
|| !FeatureFlag::AgentClientProtocol.is_enabled()
{
return AgentBackend::Provider;
}
let settings = AISettings::as_ref(ctx);
if !*settings.acp_enabled.value() {
return AgentBackend::Provider;
}
#[cfg(not(target_family = "wasm"))]
if let Some(llm_preferences) = ctx.try_get_singleton_model_as_ref::<LLMPreferences>() {
return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx);
}
let providers = settings.enabled_acp_providers();
let [provider] = providers.as_slice() else {
return AgentBackend::Provider;
};
let agent_id = provider.agent_id.trim();
let agent_id = if agent_id.is_empty() {
"codex"
} else {
agent_id
};
#[cfg(not(target_family = "wasm"))]
let launch_fingerprint =
acp_launch_fingerprint(agent_id, &provider.command, &provider.args);
#[cfg(target_family = "wasm")]
let launch_fingerprint = String::new();
AgentBackend::Acp(AcpConversationData {
provider_id: provider.id.clone(),
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
&provider.config_options,
),
})
}
/// Reconciles a conversation without agent output with the currently enabled local runtime.
///
/// Agent views can create their initial conversation before the user changes runtime settings,
/// and a provider-less attempt can leave behind an error-only exchange. Refreshing here lets
/// either case use ACP without mixing successful provider output into an ACP-owned history.
pub(crate) fn refresh_conversation_backend_without_output(
&mut self,
conversation_id: AIConversationId,
ctx: &AppContext,
) {
let Some(conversation) = self.conversation(&conversation_id) else {
return;
};
let Some(terminal_surface_id) = self.terminal_surface_id_for_conversation(&conversation_id)
else {
return;
};
let agent_backend = Self::configured_agent_backend(
terminal_surface_id,
conversation.is_viewing_shared_session(),
conversation.is_cli_agent_transcript(),
ctx,
);
if conversation.agent_backend() == &agent_backend {
return;
}
if let Some(conversation) = self.conversation_mut(&conversation_id) {
conversation.set_agent_backend_if_no_output(agent_backend);
}
}
/// Starts a new conversation in the given terminal surface's history, effectively marking the /// Starts a new conversation in the given terminal surface's history, effectively marking the
/// existing conversation (if any) as completed. /// existing conversation (if any) as completed.
/// ///
@@ -1197,55 +1318,12 @@ impl BlocklistAIHistoryModel {
is_cli_agent_transcript: bool, is_cli_agent_transcript: bool,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> AIConversationId { ) -> AIConversationId {
let agent_backend = if !is_viewing_shared_session let agent_backend = Self::configured_agent_backend(
&& !is_cli_agent_transcript terminal_surface_id,
&& cfg!(unix) is_viewing_shared_session,
&& FeatureFlag::AgentClientProtocol.is_enabled() is_cli_agent_transcript,
{ ctx,
let settings = AISettings::as_ref(ctx); );
if *settings.acp_enabled.value() {
let configured_agent_id = settings.acp_agent_id.value().trim();
let agent_id = if configured_agent_id.is_empty() {
"codex"
} else {
configured_agent_id
};
#[cfg(not(target_family = "wasm"))]
let launch_fingerprint = acp_launch_fingerprint(
agent_id,
settings.acp_agent_command.value(),
settings.acp_agent_args.value(),
);
#[cfg(target_family = "wasm")]
let launch_fingerprint = String::new();
AgentBackend::Acp(AcpConversationData {
agent_id: agent_id.to_string(),
launch_fingerprint,
session_id: None,
config_values: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id))
.map(|agent| {
#[cfg(not(target_family = "wasm"))]
if let Some(selection) = LLMPreferences::as_ref(ctx)
.selected_acp_config_for_agent(&agent.name, ctx)
{
return selection;
}
crate::ai::acp::AcpRuntimeModel::current_config_values(
&agent.config_options,
)
})
.unwrap_or_default(),
})
} else {
AgentBackend::Provider
}
} else {
AgentBackend::Provider
};
let mut new_conversation = AIConversation::new_with_agent_backend( let mut new_conversation = AIConversation::new_with_agent_backend(
is_viewing_shared_session, is_viewing_shared_session,
is_cli_agent_transcript, is_cli_agent_transcript,
@@ -1322,7 +1400,21 @@ impl BlocklistAIHistoryModel {
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) {
#[cfg(not(target_family = "wasm"))]
let remote_log_context =
remote_status_log_context(conversation, &status, error.as_ref());
conversation.update_status_with_error(status, error, terminal_surface_id, ctx); conversation.update_status_with_error(status, error, terminal_surface_id, ctx);
#[cfg(not(target_family = "wasm"))]
if let Some(context) = remote_log_context {
remote_logging::log_model_event(
ctx,
RemoteLogRecord {
level: RemoteLogLevel::Info,
message: "Agent conversation status changed".to_string(),
context,
},
);
}
} }
} }
@@ -1598,6 +1690,7 @@ impl BlocklistAIHistoryModel {
let conversation_data = AgentConversationData { let conversation_data = AgentConversationData {
agent_backend: source_conversation.agent_backend().for_fork(), agent_backend: source_conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: Some(source_conversation.usage_metadata()), conversation_usage_metadata: Some(source_conversation.usage_metadata()),
reverted_action_ids, reverted_action_ids,
@@ -1762,6 +1855,7 @@ impl BlocklistAIHistoryModel {
// be recomputed based on the retained exchanges in a follow-up. // be recomputed based on the retained exchanges in a follow-up.
let conversation_data = AgentConversationData { let conversation_data = AgentConversationData {
agent_backend: conversation.agent_backend().for_fork(), agent_backend: conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None, server_conversation_token: None,
conversation_usage_metadata: None, conversation_usage_metadata: None,
reverted_action_ids, reverted_action_ids,
@@ -1852,6 +1946,21 @@ impl BlocklistAIHistoryModel {
Ok(()) Ok(())
} }
pub fn apply_domain_tool_proposal(
&mut self,
response_stream_id: &ResponseStreamId,
conversation_id: AIConversationId,
terminal_surface_id: EntityId,
action: AIAgentAction,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
self.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?
.apply_domain_tool_proposal(response_stream_id, terminal_surface_id, action, ctx)?;
Ok(())
}
pub fn update_conversation_cost_and_usage_for_request( pub fn update_conversation_cost_and_usage_for_request(
&mut self, &mut self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
@@ -2755,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
// Placeholder authoritative. // Placeholder authoritative.
agent_backend: placeholder.agent_backend().clone(), agent_backend: placeholder.agent_backend().clone(),
// Active process-local provider runs cannot be merged from a cloud transcript.
active_provider_run_json: None,
// Cloud authoritative. // Cloud authoritative.
server_conversation_token: cloud_conversation server_conversation_token: cloud_conversation
.server_conversation_token() .server_conversation_token()
@@ -2817,6 +2929,46 @@ fn agent_id_key_from_persisted_data(conversation_data: &AgentConversationData) -
conversation_data.run_id.as_deref() conversation_data.run_id.as_deref()
} }
#[cfg(not(target_family = "wasm"))]
fn remote_status_log_context(
conversation: &AIConversation,
new_status: &ConversationStatus,
error: Option<&RenderableAIError>,
) -> Option<serde_json::Value> {
let prev_status = conversation.status();
if prev_status == new_status {
return None;
}
Some(serde_json::json!({
"event": "agent_conversation_status_changed",
"conversation_id": conversation.id().to_string(),
"parent_conversation_id": conversation.parent_conversation_id().map(|id| id.to_string()),
"agent_id": conversation.orchestration_agent_id(),
"agent_name": conversation.agent_name(),
"harness_type": conversation.orchestration_harness_type(),
"is_child": conversation.parent_conversation_id().is_some(),
"is_remote_child": conversation.is_remote_child(),
"previous_status": conversation_status_label(prev_status),
"new_status": conversation_status_label(new_status),
"new_status_is_terminal": new_status.is_done(),
"error": error.map(remote_logging::sanitize_error),
}))
}
#[cfg(not(target_family = "wasm"))]
fn conversation_status_label(status: &ConversationStatus) -> &'static str {
match status {
ConversationStatus::InProgress => "in_progress",
ConversationStatus::Success => "success",
ConversationStatus::Error => "error",
ConversationStatus::TransientError => "transient_error",
ConversationStatus::Cancelled => "cancelled",
ConversationStatus::Blocked { .. } => "blocked",
ConversationStatus::WaitingForEvents => "waiting_for_events",
}
}
/// Whether an `UpdatedConversationStatus` event represents a restoration /// Whether an `UpdatedConversationStatus` event represents a restoration
/// (the conversation was re-loaded for a terminal surface; the underlying /// (the conversation was re-loaded for a terminal surface; the underlying
/// `ConversationStatus` did not change) or a real status set, in which case /// `ConversationStatus` did not change) or a real status set, in which case
+295 -2
View File
@@ -21,8 +21,10 @@ use crate::ai::agent::conversation::{
ServerAIConversationMetadata, ServerAIConversationMetadata,
}; };
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput, AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentExchange, AIAgentExchangeId,
RenderableAIError, Shared, TransientNetworkErrorKind, UserQueryMode, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType,
AIAgentOutputStatus, AIAgentText, AIAgentTextSection, AgentOutputText, FinishedAIAgentOutput,
MessageId, RenderableAIError, RunningCommand, Shared, TransientNetworkErrorKind, UserQueryMode,
}; };
use crate::ai::ambient_agents::{ use crate::ai::ambient_agents::{
conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus,
@@ -78,6 +80,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
assert_eq!( assert_eq!(
conversation.agent_backend(), conversation.agent_backend(),
&AgentBackend::Acp(AcpConversationData { &AgentBackend::Acp(AcpConversationData {
provider_id: "legacy".to_string(),
agent_id: "codex".to_string(), agent_id: "codex".to_string(),
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]), launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
session_id: None, session_id: None,
@@ -88,6 +91,82 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
}); });
} }
#[test]
fn enabling_acp_refreshes_a_provider_conversation_with_only_failed_output() {
let _acp_flag = FeatureFlag::AgentClientProtocol.override_enabled(true);
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(false, ctx)
.expect("ACP setting should update");
});
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let conversation_id = history_model.update(&mut app, |model, ctx| {
model.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
history_model.read(&app, |model, _| {
assert_eq!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
&AgentBackend::Provider
);
});
history_model.update(&mut app, |model, _| {
let now = Local::now();
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(AIAgentExchange {
id: AIAgentExchangeId::new(),
input: Vec::new(),
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error {
output: None,
error: RenderableAIError::other("No AI provider configured", true),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("none"),
request_cost: None,
coding_model_id: LLMId::from("none"),
cli_agent_model_id: LLMId::from("none"),
computer_use_model_id: LLMId::from("none"),
response_initiator: None,
});
});
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.acp_enabled
.set_value(true, ctx)
.expect("ACP setting should update");
});
history_model.update(&mut app, |model, ctx| {
model.refresh_conversation_backend_without_output(conversation_id, ctx);
});
history_model.read(&app, |model, _| {
assert!(matches!(
model
.conversation(&conversation_id)
.expect("conversation should exist")
.agent_backend(),
AgentBackend::Acp(_)
));
});
});
}
/// Helper function to create a PersistedAIInput for testing /// Helper function to create a PersistedAIInput for testing
fn create_persisted_query( fn create_persisted_query(
query_text: &str, query_text: &str,
@@ -144,6 +223,101 @@ fn repeated_command_steering_reuses_the_active_cli_subtask() {
}); });
} }
#[test]
fn provider_tool_proposal_creates_exchange_for_tool_first_cli_turn() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let stream_id = ResponseStreamId::new_for_test();
let action_id = AIAgentActionId::from("monitor-tool-call".to_owned());
let (conversation_id, cli_task_id, action) =
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let root_task_id = model
.conversation(&conversation_id)
.expect("conversation should exist")
.get_root_task_id()
.clone();
model
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(root_task_id, Vec::new())]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
stream_id.clone(),
terminal_view_id,
ctx,
)
.expect("root response exchange should be recorded");
model.initialize_output_for_response_stream(
&stream_id,
conversation_id,
terminal_view_id,
warp_multi_agent_api::response_event::StreamInit {
request_id: "provider-request".to_owned(),
conversation_id: "provider-conversation".to_owned(),
run_id: "provider-run".to_owned(),
},
ctx,
);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
BlockId::new(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let action = AIAgentAction {
id: action_id.clone(),
task_id: cli_task_id.clone(),
action: AIAgentActionType::FileGlob {
patterns: vec!["*.rs".to_owned()],
path: None,
},
requires_result: true,
tool_name: Some("file_glob".to_owned()),
};
model
.apply_domain_tool_proposal(
&stream_id,
conversation_id,
terminal_view_id,
action.clone(),
ctx,
)
.expect("tool-first CLI proposal should attach to a lazy exchange");
(conversation_id, cli_task_id, action)
});
history_model.read(&app, |model, _| {
let conversation = model
.conversation(&conversation_id)
.expect("conversation should exist");
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI subtask should exist");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
conversation.exchange_id_for_action(&action.id),
cli_task.last_exchange().map(|exchange| exchange.id)
);
assert!(conversation.contains_action(&action.id));
});
});
}
#[test] #[test]
fn deactivating_cli_subtask_clears_activity_without_deleting_task() { fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -193,6 +367,125 @@ fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
}); });
} }
#[test]
fn completed_command_assessment_survives_cli_subtask_deactivation_on_root() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let block_id = BlockId::new();
let assessment_output = "The command completed successfully.";
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let monitor_exchange =
create_exchange_with_query("Check the command status.", Local::now(), None);
let monitor_exchange_id = monitor_exchange.id;
let conversation = model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
conversation
.append_task_exchange_for_test(
&cli_task_id,
monitor_exchange,
terminal_view_id,
ctx,
)
.expect("monitor exchange should be appended to the CLI task");
let now = Local::now();
let assessment_exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Assess the completed command.".to_string(),
context: Arc::from([]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}],
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Success {
output: Shared::new(AIAgentOutput {
messages: vec![AIAgentOutputMessage {
id: MessageId::new("assessment-output".to_string()),
message: AIAgentOutputMessageType::Text(AIAgentText {
sections: vec![AIAgentTextSection::PlainText {
text: AgentOutputText::from(assessment_output.to_string()),
}],
}),
citations: vec![],
}],
..Default::default()
}),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
response_initiator: None,
};
let assessment_exchange_id = assessment_exchange.id;
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(assessment_exchange);
model
.deactivate_cli_subagent_task_for_conversation(&block_id, conversation_id)
.expect("CLI subtask should deactivate");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(!conversation.has_active_subagent());
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI task should be retained after deactivation");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
cli_task.last_exchange().map(|exchange| exchange.id),
Some(monitor_exchange_id)
);
let root_exchange = conversation
.latest_visible_exchange()
.expect("root assessment output should remain visible");
assert_eq!(root_exchange.id, assessment_exchange_id);
assert!(matches!(
root_exchange.input.as_slice(),
[AIAgentInput::CommandCompletionAssessment { .. }]
));
assert!(root_exchange.input[0].display_query().is_none());
assert_eq!(
root_exchange.format_output_for_copy(None),
assessment_output
);
});
});
}
#[test] #[test]
fn monitoring_a_different_block_preserves_completed_cli_task_history() { fn monitoring_a_different_block_preserves_completed_cli_task_history() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
@@ -782,7 +782,11 @@ impl AskUserQuestionView {
}; };
ctx.subscribe_to_model(&action_model, |me, _, event, ctx| { ctx.subscribe_to_model(&action_model, |me, _, event, ctx| {
if event.action_id() != me.action_id() { if event.action_id() != me.action_id()
|| event
.conversation_id()
.is_some_and(|conversation_id| conversation_id != me.conversation_id)
{
return; return;
} }
@@ -879,7 +883,8 @@ impl AskUserQuestionView {
/// conversations still render deterministically. /// conversations still render deterministically.
fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> { fn action_status(&self, app: &AppContext) -> Option<AIActionStatus> {
let action_model = self.action_model.as_ref(app); let action_model = self.action_model.as_ref(app);
if let Some(status) = action_model.get_action_status(self.action_id()) { if let Some(status) = action_model.get_action_status(self.conversation_id, self.action_id())
{
return Some(status); return Some(status);
} }
@@ -366,7 +366,7 @@ pub enum CodeDiffState {
/// The diff is received, but is queued for interaction behind another action. /// The diff is received, but is queued for interaction behind another action.
Queued, Queued,
/// The user is reviewing (and possibly editing) the code diff. /// The user is reviewing (and possibly editing) the code diff.
/// Unlike requested commands, a [`CodeDiffView`] is only created upon stream completion. /// The view is created as soon as the requested edit is present in streaming output.
WaitingForUser, WaitingForUser,
/// If the payload is some, the code diff was accepted but the individual file changes have not /// If the payload is some, the code diff was accepted but the individual file changes have not
/// been fully computed and saved yet. We cache the accepted diff state to collect unified diffs /// been fully computed and saved yet. We cache the accepted diff state to collect unified diffs
@@ -695,12 +695,26 @@ impl CodeDiffView {
session_platform, session_platform,
ctx, ctx,
); );
let action_id = (*action_id).clone();
ctx.subscribe_to_model( ctx.subscribe_to_model(
&action_model, &action_model,
move |me, action_model, event, ctx| match event { move |me, action_model, event, ctx| match event {
BlocklistAIActionEvent::FinishedAction { action_id, .. } if !me.is_complete() => { BlocklistAIActionEvent::FinishedAction {
match action_model.as_ref(ctx).get_action_status(&me.action_id) { action_id: event_action_id,
conversation_id: event_conversation_id,
..
} if !me.is_complete()
&& *event_action_id == me.action_id
&& me.identifiers.client_conversation_id == Some(*event_conversation_id) =>
{
let Some(conversation_id) = me.identifiers.client_conversation_id else {
return;
};
match action_model
.as_ref(ctx)
.get_action_status(conversation_id, &me.action_id)
{
Some(AIActionStatus::Blocked) => { Some(AIActionStatus::Blocked) => {
me.state = CodeDiffState::WaitingForUser; me.state = CodeDiffState::WaitingForUser;
ctx.notify(); ctx.notify();
@@ -16,5 +16,6 @@ pub(super) mod search_codebase;
pub(crate) mod search_results_common; pub(crate) mod search_results_common;
pub(crate) mod suggested_unit_tests; pub(crate) mod suggested_unit_tests;
pub(super) mod summarization; pub(super) mod summarization;
pub(crate) mod tool_pane;
pub(super) mod web_fetch; pub(super) mod web_fetch;
pub(super) mod web_search; pub(super) mod web_search;

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